7 min read

Building a GPS navigation PWA with Next.js + Mapbox (Wayline case study)

PWANext.jsMapboxTurf.jsGPS
Building a GPS navigation PWA with Next.js + Mapbox (Wayline case study)

The problem: navigating while avoiding certain zones

Every GPS gives you the fastest route. But sometimes you want to avoid certain zones - a construction site blocking the street every morning, a neighborhood you'd rather skip, a road where you know there's a speed camera.

That's the need that gave birth to Wayline: a GPS navigation PWA where you define your avoidance zones, and the app automatically recalculates routes that go around them.

Here are the technical choices I made, and why.

The stack: why not a native app?

For GPS navigation, the reflex is React Native or Flutter. I chose PWA Next.js + Mapbox for several reasons:

  • No App Store review: instant deployment, updates without review
  • Native geolocation via the Web API: navigator.geolocation works as well as a native app on recent iOS and Android
  • Mapbox GL JS: ultra-fast vector rendering engine, same capabilities as native SDKs
  • Service Worker: I can cache the map and routes for offline use
  • No install friction: the user opens the URL, adds it to the home screen, done

The only real trade-off: no access to iOS push notifications outside an installed PWA. For Wayline, that's not critical - GPS is used while out, not in the background.

Computing a route with zone avoidance

This is the heart of the project. Mapbox offers a routing API (Directions API) that takes waypoints and returns the optimal route. But it doesn't know how to "avoid this polygon".

The trick: we compute a base route, check if it crosses an avoidance zone, and if so, we insert a waypoint that forces the detour.

To check the intersection between the route line and the polygon, I use Turf.js:

import * as turf from '@turf/turf';

function routeIntersectsZone(routeGeoJSON, zonePolygon) {
  return turf.booleanIntersects(routeGeoJSON, zonePolygon);
}

function findDetourWaypoint(zonePolygon) {
  const center = turf.centroid(zonePolygon);
  const bbox = turf.bbox(zonePolygon);
  // Place the waypoint slightly outside the north of the zone
  return [bbox[0] - 0.01, bbox[3] + 0.01];
}

The simplified algo:

  1. Call Mapbox Directions with origin + destination
  2. For each defined zone: check if the route crosses it
  3. If so, compute a detour waypoint
  4. Re-call Directions with the inserted waypoint
  5. Return the final route

That's still 1-2 API calls per route calculation, but the experience is smooth (<300ms on average).

Local storage with SQLite

For avoidance zones and route history, I chose server-side SQLite rather than Postgres or a managed service. Why:

  • Wayline is a personal project, no need to scale to thousands of users
  • SQLite runs in the same Node.js process (better-sqlite3), zero connection overhead
  • Simple backups: a single .db file, I can rsync it to the NAS
  • Near-instant reads for the user's zones (typically 5-20 zones)

For spatial queries (does this zone contain this point?), Turf.js does the job server-side. No need for PostGIS.

const Database = require('better-sqlite3');
const db = new Database('wayline.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS zones (
    id INTEGER PRIMARY KEY,
    user_id TEXT NOT NULL,
    name TEXT,
    geojson TEXT NOT NULL,
    created_at INTEGER
  );
`);

const getUserZones = db.prepare('SELECT * FROM zones WHERE user_id = ?');

Offline mode with Service Worker

This is the real differentiator of PWA vs classic native app. With a service worker, I can cache:

  • The app shell (HTML/CSS/JS)
  • Already visited Mapbox tiles (Mapbox GL handles this natively with a configured cache)
  • The user's zones (stored in localStorage)
  • The last computed route

Result: the user can keep seeing their map and route even when losing the network, which happens regularly in rural zones or basements.

Minimal service worker setup (with next-pwa):

// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/api\.mapbox\.com\/styles\//,
      handler: 'CacheFirst',
      options: { cacheName: 'mapbox-tiles', expiration: { maxAgeSeconds: 7 * 24 * 60 * 60 } }
    }
  ]
});

The pitfalls I hit

1. navigator.geolocation can lie. On some mobile browsers, accuracy varies from 5m to 500m frame to frame. I had to implement a simplified Kalman filter to smooth the tracking.

2. Mapbox tiles get expensive past the free tier. If Wayline had massive traffic, I'd switch to an open-source tile provider (Stadia Maps, Protomaps). For now, the free tier is more than enough.

3. iOS Safari and the service worker. Apple has its own rules: service workers get unloaded aggressively, the cache isn't guaranteed. For critical use cases (a cyclist who needs to consult their route), I recommend doubling up with localStorage.

The verdict

MetricValue
Initial JS bundle~140 KB gzip
Time to Interactive<2s on 4G
Route recompute with avoidance250-350 ms
Offline operation✅ Yes (map + last route)
User-side storage~3 MB after several routes

What I take away from it

For 95% of GPS use cases, a well-built PWA replaces a native app. The only real benefit of a native app for a navigation project is continuous background use (like Waze talking to you while driving). If you don't need that, a PWA is faster to develop, easier to deploy, and offers a comparable experience.

Mapbox + Turf.js is a powerful combination: Mapbox for rendering and standard routing, Turf.js for everything that goes beyond their API (intersections, buffers, custom distances). With these two building blocks, you can build pretty much any geospatial logic in the browser.