Geolocation Integration Guide
What is the Geolocation API?
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.
██████╗ ███████╗ ██████╗ ██╗ ██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗
██╔════╝ ██╔════╝██╔═══██╗██║ ██╔═══██╗██╔════╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║
██║ ███╗█████╗ ██║ ██║██║ ██║ ██║██║ ███████║ ██║ ██║██║ ██║██╔██╗ ██║
██║ ██║██╔══╝ ██║ ██║██║ ██║ ██║██║ ██╔══██║ ██║ ██║██║ ██║██║╚██╗██║
╚██████╔╝███████╗╚██████╔╝███████╗╚██████╔╝╚██████╗██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║
╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝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:
- 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. - 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, andprompt— and thechangeevent when it flips mid-session. - HTTPS only.
navigator.geolocationis undefined behaviour (silently fails / throws) on plain HTTP.localhostis treated as secure for dev; everything else needs TLS. - A
watchPositionyou don't clear is a battery leak. Every watcher keeps the GPS/radio warm. In React this meansclearWatchin the effect cleanup — non-negotiable.
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/native-hardware-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
}
}Consent / permission flow
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 };
}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):
- Google Maps Platform Geocoding API —
GET /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".
Privacy & consent (KDPA 2019)
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
changeevent and stop collecting immediately when it flips todenied. - 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.
// 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 likex-vercel-ip-country; Cloudflare exposesrequest.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.
// 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 (PostGISST_DWithinover 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 ondenied. - Delivery / rider tracking.
watchPositionin a"use client"component, high accuracy, only while the rider is on an active delivery — andclearWatchthe 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
clearWatchand 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.timestampis epoch ms (UTC). Store UTC, render in Africa/Nairobi (EAT, UTC+3) for riders and ops dashboards. - HTTPS everywhere.
navigator.geolocationonly works in a secure context — fine on Vercel/Cloudflare prod andlocalhostdev; 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 — neverNEXT_PUBLIC_*, never in the browser bundle.