Geolocation Integration Guide

Technology: geolocation · Category: tooling · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/geolocation

Insight:

Geolocation is a built-in browser API, not a package — the work is all in how you ask (explicit, revocable consent), when you stop (clearWatch or you leak battery), and what you may store (every lat/lng is KDPA-2019 personal data). The core trade-off: the device API is GPS-precise but consent- and HTTPS-gated, while IP geolocation is silent and free but only city-accurate (and wrong behind Kenyan carrier NAT/VPNs). codeAmani reaches for the precise signal only where a feature needs it — the M-Pesa agent locator, rider tracking — and defaults from IP before the user opts in.

 ██████╗ ███████╗ ██████╗ ██╗      ██████╗  ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗   ██╗
██╔════╝ ██╔════╝██╔═══██╗██║     ██╔═══██╗██╔════╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗  ██║
██║  ███╗█████╗  ██║   ██║██║     ██║   ██║██║     ███████║   ██║   ██║██║   ██║██╔██╗ ██║
██║   ██║██╔══╝  ██║   ██║██║     ██║   ██║██║     ██╔══██║   ██║   ██║██║   ██║██║╚██╗██║
╚██████╔╝███████╗╚██████╔╝███████╗╚██████╔╝╚██████╗██║  ██║   ██║   ██║╚██████╔╝██║ ╚████║
 ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝  ╚═════╝╚═╝  ╚═╝   ╚═╝   ╚═╝ ╚═════╝ ╚═╝  ╚═══╝

Geolocation Integration Guide

Focus: get a device's position from the browser with explicit, revocable consent — ask for permission, read it once or watch it over time, always clean up the watcher, and treat every latitude/longitude as KDPA-regulated personal data. IP geolocation is the coarse, consent-free fallback.

Overview

Location is a built-in web API, not a package. navigator.geolocation ships in every modern browser, so for the on-device case there is nothing to npm install — the work is all in how you ask, when you stop, and what you are allowed to store.

There are two fundamentally different ways to know where a user is, and they sit at opposite ends of the accuracy/consent spectrum:

  1. Device geolocation (navigator.geolocation) — GPS, Wi-Fi, and cell triangulation, accurate to a few metres outdoors. It is gated behind explicit user consent and only works in a secure context (HTTPS). This is what you use for a rider's live position or "find my location" on a map.
  2. IP-based geolocation — derive an approximate city/region from the request IP, server-side, with no prompt and no GPS. Accurate only to city level (and wrong behind VPNs/carrier NAT — common on Kenyan mobile networks). This is your coarse fallback for defaulting a country/currency before the user opts in.

The whole design tension is: the browser API is precise but requires consent and battery; the IP fallback is free and silent but coarse. A good product asks for the precise signal only when the feature genuinely needs it, and degrades gracefully to the coarse one when permission is denied.

Three hard truths shape every integration:

Official Documentation

Source URL What it covers
MDN Geolocation API https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API getCurrentPosition, PositionOptions, error codes, HTTPS requirement
MDN watchPosition https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition Watcher signature, clearWatch, React cleanup
MDN Permissions API https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API navigator.permissions.query, state values, change event
W3C Geolocation spec https://www.w3.org/TR/geolocation/ The normative model, coordinate fields, security/privacy
web.dev — user location https://web.dev/articles/user-location Consent UX patterns, accuracy/battery trade-offs
Google Maps Geocoding https://developers.google.com/maps/documentation/geocoding/overview Reverse geocoding lat/lng → address

The shape of a position

A success callback receives a GeolocationPosition: a coords object plus a timestamp.

coords field Meaning Notes
latitude / longitude WGS84 decimal degrees The PII. Treat as regulated.
accuracy Radius of confidence, metres Always present. ~5–20 m with GPS, hundreds with Wi-Fi/IP.
altitude / altitudeAccuracy Metres above sea level null on most phones.
heading Degrees from true north (0–360) null when stationary.
speed Metres/second null unless moving.

Errors arrive as a GeolocationPositionError with a numeric code:

Code Constant Meaning Your move
1 PERMISSION_DENIED User said no (or revoked) Fall back to IP / manual entry
2 POSITION_UNAVAILABLE No fix available Retry or fall back
3 TIMEOUT Didn't resolve within timeout Retry with a longer timeout

PositionOptions — the three knobs

interface PositionOptions {
  enableHighAccuracy?: boolean; // default false — true asks for GPS (slower, more battery)
  timeout?: number;             // default Infinity (ms) — max wait for a fix
  maximumAge?: number;          // default 0 (ms) — accept a cached fix up to this old
}

enableHighAccuracy: false + a non-zero maximumAge is the cheap, battery-friendly default; enableHighAccuracy: true + maximumAge: 0 is the precise, expensive one. Choose per feature, not globally.


Pattern 1 — check permission, then getCurrentPosition

Query the Permissions API first so you can tailor UX (don't slam an unprompted prompt on page load — explain why you need location, then trigger it from a user gesture).

// lib/geolocation.ts
export type GeoState = "granted" | "denied" | "prompt" | "unsupported";

export async function getGeoPermission(): Promise<GeoState> {
  if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
    return "unsupported";
  }
  // Permissions API isn't in every browser; degrade to "prompt".
  if (!("permissions" in navigator)) return "prompt";
  try {
    const status = await navigator.permissions.query({ name: "geolocation" });
    return status.state; // "granted" | "denied" | "prompt"
  } catch {
    return "prompt";
  }
}

/** Promise wrapper around the callback-based getCurrentPosition. */
export function getCurrentPosition(
  options: PositionOptions = { enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 },
): Promise<GeolocationPosition> {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, options);
  });
}
// usage — trigger from a click, never on mount
async function locateMe() {
  if ((await getGeoPermission()) === "denied") {
    // Browser won't re-prompt once denied — guide the user to site settings,
    // or fall back to coarse IP lookup / manual address entry.
    return useIpFallback();
  }
  try {
    const pos = await getCurrentPosition();
    const { latitude, longitude, accuracy } = pos.coords;
    // ... use lat/lng. accuracy (m) tells you how much to trust it.
  } catch (err) {
    const code = (err as GeolocationPositionError).code;
    if (code === 1) return useIpFallback();      // PERMISSION_DENIED
    if (code === 3) return getCurrentPosition({ timeout: 20_000 }); // TIMEOUT → retry
    return useIpFallback();                       // POSITION_UNAVAILABLE
  }
}
flowchart TD
    A["Feature needs location"] --> B{"navigator.geolocation exists?"}
    B -->|"no"| F["IP fallback / manual entry"]
    B -->|"yes"| C["permissions.query(geolocation)"]
    C --> D{"state?"}
    D -->|"granted"| G["getCurrentPosition · use fix"]
    D -->|"prompt"| E["Show rationale · user gesture triggers prompt"]
    D -->|"denied"| F
    E --> H{"User choice"}
    H -->|"allow"| G
    H -->|"block"| F

Pattern 2 — watchPosition with React cleanup

For live tracking (a rider en route, a delivery on a map), watchPosition registers a handler that fires only when the position changes. It returns a numeric watch id you must pass to clearWatch — in React, in the effect's cleanup function.

"use client";
import { useEffect, useState } from "react";

type Fix = { lat: number; lng: number; accuracy: number; at: number };

export function useWatchPosition(active: boolean) {
  const [fix, setFix] = useState<Fix | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!active || typeof navigator === "undefined" || !("geolocation" in navigator)) {
      return;
    }

    const watchId = navigator.geolocation.watchPosition(
      (pos) => {
        const c = pos.coords;
        setFix({ lat: c.latitude, lng: c.longitude, accuracy: c.accuracy, at: pos.timestamp });
        setError(null);
      },
      (err) => setError(`(${err.code}) ${err.message}`),
      { enableHighAccuracy: true, timeout: 15_000, maximumAge: 5_000 },
    );

    // CRITICAL: stop the watcher → releases the GPS radio, saves battery.
    return () => navigator.geolocation.clearWatch(watchId);
  }, [active]);

  return { fix, error };
}
sequenceDiagram
    participant C as Component (mount)
    participant G as navigator.geolocation
    participant D as Device GPS/radio
    C->>G: watchPosition(success, error, opts)
    G-->>C: watchId
    D->>G: position changed
    G->>C: success(GeolocationPosition)
    D->>G: position changed again
    G->>C: success(...)
    Note over C,G: component unmounts
    C->>G: clearWatch(watchId)
    G->>D: release radio · stop polling

Foreground only. The web Geolocation API does not run in the background — once the tab is backgrounded or closed, updates stop. There is no web equivalent of a native background-location service. For genuine background rider tracking you need a native/PWA approach or periodic foreground check-ins; don't promise continuous tracking the web can't deliver.


Accuracy & battery trade-offs

Goal enableHighAccuracy maximumAge Source Cost
"Roughly where are they" (default country/branch) false large (e.g. 5 min) Wi-Fi/cell/cache cheap
"Pin on a map, one-off" true 0 GPS one GPS wake
"Live route tracking" true small (e.g. 5 s) GPS continuous battery-heavy

Rules of thumb: request high accuracy only when the UI actually plots a precise point; let maximumAge serve a recent cached fix instead of waking the GPS; stop watchers the instant the screen is no longer visible. On the 2G/3G-and-budget-Android reality of the Kenyan market, an always-on high-accuracy watcher will drain a rider's phone before lunch — gate it behind "I'm on a delivery" state.

Geofencing (concept)

The web has no native geofence API (watchPosition won't wake your code when the app is closed). You approximate it in the foreground: keep a target point + radius, and on each watchPosition update compute the great-circle (haversine) distance; when it crosses the radius, fire your event ("rider arrived at the customer", "near the M-Pesa agent").

/** Haversine distance in metres between two lat/lng points. */
export function distanceMeters(a: [number, number], b: [number, number]): number {
  const R = 6_371_000; // earth radius (m)
  const toRad = (d: number) => (d * Math.PI) / 180;
  const dLat = toRad(b[0] - a[0]);
  const dLng = toRad(b[1] - a[1]);
  const lat1 = toRad(a[0]);
  const lat2 = toRad(b[0]);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}
// inside watchPosition success: if (distanceMeters(here, agent) < 50) flagArrived();

For true server-side / background geofencing you push raw points to your backend and evaluate the geometry there (PostGIS ST_DWithin, or a managed service).

Reverse geocoding

Coordinates are not human-readable — "−1.2921, 36.8219" means nothing to a customer; "Kenyatta Avenue, Nairobi" does. Reverse geocoding turns lat/lng into an address, and it's a server-side call to a provider (so your API key never ships to the browser):

Cache results (coordinates rarely move much) and call from a route handler, not the client. Forward geocoding (address → lat/lng) uses the same providers for "enter your delivery address".


Location is personal data under Kenya's Data Protection Act, 2019 (KDPA), and precise location is among the most sensitive categories you can hold — it reveals home, workplace, and movement patterns. Treat every stored lat/lng accordingly.

// React to revocation mid-session
const status = await navigator.permissions.query({ name: "geolocation" });
status.addEventListener("change", () => {
  if (status.state === "denied") stopAllTracking(); // clearWatch + stop persisting
});

IP-based geolocation (coarse fallback)

When consent is denied/unsupported, or you just want a sensible default before asking, resolve an approximate location from the request IP — server-side, no prompt:

// app/api/locate/route.ts — Next.js on Vercel edge
export function GET(req: Request) {
  const country = req.headers.get("x-vercel-ip-country") ?? "KE";
  const city = req.headers.get("x-vercel-ip-city") ?? null;
  return Response.json({ country, city, source: "ip", precise: false });
}

codeAmani notes

Official docs: