← Back to dashboard
geolocationtoolingfresh

Geolocation Integration Guide

What is the Geolocation API?

The real model

Consent-gated device location (GPS/Wi-Fi/cell), foreground-only, HTTPS-only — with IP as the coarse silent fallback.

Two callbacks (success → GeolocationPosition, error → code 1/2/3 for denied/unavailable/timeout). watchPosition keeps the radio warm, so clearWatch in your React effect cleanup is mandatory or you leak battery. There is no background location and no native geofence on the web — you approximate geofencing with haversine distance on each foreground update. Crucially for codeAmani: every lat/lng is KDPA-2019 personal data — explicit consent, data minimisation, retention limits. Use it for the M-Pesa agent locator and rider tracking; default from IP before the user opts in.

Six location primitives

From one-shot fixes to live tracking — and the privacy rules that bind them.

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

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:

  • Consent is mandatory and revocable. The first call triggers a browser prompt. The user can deny it, or grant then revoke it later in site settings. Your code must handle granted, denied, and prompt — and the change event when it flips mid-session.
  • HTTPS only. navigator.geolocation is undefined behaviour (silently fails / throws) on plain HTTP. localhost is treated as secure for dev; everything else needs TLS.
  • A watchPosition you don't clear is a battery leak. Every watcher keeps the GPS/radio warm. In React this means clearWatch in the effect cleanup — non-negotiable.

Official Documentation

SourceURLWhat it covers
MDN Geolocation APIhttps://developer.mozilla.org/en-US/docs/Web/API/Geolocation_APIgetCurrentPosition, PositionOptions, error codes, HTTPS requirement
MDN watchPositionhttps://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPositionWatcher signature, clearWatch, React cleanup
MDN Permissions APIhttps://developer.mozilla.org/en-US/docs/Web/API/Permissions_APInavigator.permissions.query, state values, change event
W3C Geolocation spechttps://www.w3.org/TR/geolocation/The normative model, coordinate fields, security/privacy
web.dev — user locationhttps://web.dev/articles/native-hardware-user-locationConsent UX patterns, accuracy/battery trade-offs
Google Maps Geocodinghttps://developers.google.com/maps/documentation/geocoding/overviewReverse geocoding lat/lng → address

The shape of a position

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

coords fieldMeaningNotes
latitude / longitudeWGS84 decimal degreesThe PII. Treat as regulated.
accuracyRadius of confidence, metresAlways present. ~5–20 m with GPS, hundreds with Wi-Fi/IP.
altitude / altitudeAccuracyMetres above sea levelnull on most phones.
headingDegrees from true north (0–360)null when stationary.
speedMetres/secondnull unless moving.

Errors arrive as a GeolocationPositionError with a numeric code:

CodeConstantMeaningYour move
1PERMISSION_DENIEDUser said no (or revoked)Fall back to IP / manual entry
2POSITION_UNAVAILABLENo fix availableRetry or fall back
3TIMEOUTDidn't resolve within timeoutRetry with a longer timeout

PositionOptions — the three knobs

TypeScript
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).

TypeScript
// 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);
  });
}
TypeScript
// 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
  }
}

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.

TSX
"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 };
}

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

GoalenableHighAccuracymaximumAgeSourceCost
"Roughly where are they" (default country/branch)falselarge (e.g. 5 min)Wi-Fi/cell/cachecheap
"Pin on a map, one-off"true0GPSone GPS wake
"Live route tracking"truesmall (e.g. 5 s)GPS continuousbattery-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").

TypeScript
/** 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):

  • Google Maps Platform Geocoding APIGET /maps/api/geocode/json?latlng=...&key=..., strong East Africa coverage.
  • Mapbox Geocoding — alternative, generous free tier.

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.

  • Lawful basis + explicit consent. Under KDPA you need a lawful basis to process location, and for tracking that basis is almost always consent: freely given, specific, informed, and revocable. The browser prompt is the technical gate; your UI must supply the informed part — say what you collect, why, and for how long before you trigger the prompt.
  • Purpose limitation. Collect location only for the stated purpose (e.g. "to match you with the nearest M-Pesa agent"). Don't quietly reuse a delivery's GPS trail for marketing.
  • Data minimisation. Don't store a fix more precise than the feature needs. An agent-locator needs ~city/neighbourhood; round or truncate coordinates. Store the result (nearest agent) not the raw trail when you can.
  • Retention & deletion. Define a retention window for location history and delete on schedule. Support the data-subject rights KDPA grants (access, correction, erasure).
  • Security. Location PII gets the same treatment as other PII — encrypted at rest, access-controlled (Supabase RLS so a rider sees only their own trail), never in logs or client bundles.
  • Revocation is real. A user can revoke geolocation in browser settings any time; listen for the Permissions change event and stop collecting immediately when it flips to denied.
  • Honour the denial. Once denied, the browser won't re-prompt. Never nag or try to coerce the permission — degrade to the IP/manual fallback gracefully.
TypeScript
// 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:

  • Edge headers. On Vercel/Cloudflare the platform already geolocates the request. Vercel exposes request.geo (country, city, region) and headers like x-vercel-ip-country; Cloudflare exposes request.cf.country / CF-IPCountry. Zero extra calls.
  • Accuracy ceiling: city, and often wrong. Kenyan mobile traffic frequently routes through carrier NAT/gateways, and VPNs lie — so use IP geolocation to default a currency/country/branch, never to make a precise or trust-sensitive decision.
TypeScript
// 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

  • Nearest M-Pesa agent locator. One-shot getCurrentPosition (high accuracy, on tap) → server-side query (PostGIS ST_DWithin over agent coordinates, or haversine) → reverse-geocode the agent for a readable address. Default the search centre from IP before the user grants permission, so the page is useful even on denied.
  • Delivery / rider tracking. watchPosition in a "use client" component, high accuracy, only while the rider is on an active delivery — and clearWatch the moment the delivery ends or the screen unmounts. Remember: web tracking is foreground only; for background you need a native shell or periodic check-ins, so design the ops flow around that limit.
  • Consent-first, KDPA-first. Show a plain-language rationale before the prompt; persist a consent record; expose a "stop sharing my location" control that calls clearWatch and halts persistence. Round stored coordinates to the minimum precision the feature needs.
  • Mobile-first / low-bandwidth. Don't auto-prompt on load (kills trust and battery). Lazy-load any map SDK. The accuracy/battery table above is a budget-Android survival guide.
  • EAT timezone. position.timestamp is epoch ms (UTC). Store UTC, render in Africa/Nairobi (EAT, UTC+3) for riders and ops dashboards.
  • HTTPS everywhere. navigator.geolocation only works in a secure context — fine on Vercel/Cloudflare prod and localhost dev; never expect it on a plain-HTTP preview.
  • Keys server-side. Geocoding (Google/Mapbox) API keys live in .env.local / Vercel env and are called from route handlers — never NEXT_PUBLIC_*, never in the browser bundle.