Offline PWA With Interactive Maps: Next.js and Leaflet#
Most Next.js PWA tutorials stop at a to-do list or a news feed: text is trivial to cache, and "offline mode" boils down to "show whatever already loaded." An interactive map is a different level of difficulty entirely. Leaflet touches window and document the moment its module is imported, map tiles weigh tens of megabytes and blow past the Cache Storage quota on iOS, and route state needs to survive a total loss of connectivity in the middle of a run through the woods, where there is no signal at all.
I went through all of this building RunCalculator Pro — a free running route planner at runcalculator.pro. Click on the map and the app builds a route, an elevation profile, and calculates calories, steps, pace, and time; it can generate a random route for a target distance and export the whole thing as GPX. All of it runs entirely on the client, installs as an app, and keeps working with no network. What follows are concrete technical decisions, not a general pitch for how "PWAs are great."
Why an offline map is a rare, hard case#
A typical PWA has one obvious data source — an API you cache. A map has three. There's the Leaflet JS bundle itself, which must never end up in server rendering. There are OpenStreetMap tiles — thousands of small PNG/WebP requests for a single screen. And there's user input — clicks that form a route and must persist locally. Each of the three breaks in its own way if the architecture isn't designed up front, rather than patched after the fact.
The error almost everyone hits when porting Leaflet into Next.js: the library crashes at build time or on the first server render with ReferenceError: window is not defined. This isn't a Leaflet bug — it was written for the browser from day one and never hides that dependency. react-leaflet has a similar problem: even wrapped in dynamic, the MapContainer component sometimes throws a re-initialization error on unmount/remount (say, when the app navigates quickly between tabs), because Leaflet keeps internal state directly on the DOM node. For a map with heavy interaction — clicks, draggable markers, custom panes for labels — it's often simpler and more predictable to work with Leaflet's own imperative API directly, rather than through react-leaflet's JSX wrappers, and reserve the latter for simple static maps.
SSR and Leaflet: working around "window is not defined"#
The fix is standard for Next.js, with one nuance: ssr: false needs to wrap the component that imports Leaflet, not merely one that consumes its props. If the leaflet import sits at the top level of a file that happens to end up in the server dependency graph, the error surfaces before dynamic() ever gets a chance to run.
// components/map/InteractiveMap.tsx
'use client';
import dynamic from 'next/dynamic';
// Leaflet touches window at module import time,
// so ssr: false is not an optimization — it's the only option that works.
const MapClient = dynamic(
() => import('./MapClient').then((mod) => mod.MapClient),
{
ssr: false,
loading: () => <MapSkeleton />,
}
);
export function InteractiveMap({ className }: { className?: string }) {
return <MapClient className={className} />;
}MapClient itself is a separate file with 'use client' at the top, where it's finally safe to import Leaflet directly:
// components/map/MapClient.tsx (simplified)
'use client';
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
export function MapClient({ className }: { className?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, {
center: [55.75, 37.6],
zoom: 13,
preferCanvas: true, // canvas renders faster than DOM for tracks with hundreds of points
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
setIsReady(true);
// Leaflet measures the container once, at init time. If the map
// was hidden (inactive tab, an expanding panel animation), the
// size will be zero — recalculate once layout has settled.
requestAnimationFrame(() => map.invalidateSize());
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return <div ref={containerRef} className={className} style={{ minHeight: 400 }} />;
}In practice, a single requestAnimationFrame isn't always enough: if the map opens inside an expanding panel or right after a CSS animation, the container can have zero height at init time. The robust approach is to not rely on one frame at all — attach a ResizeObserver to the container and call invalidateSize() on every size change, plus a handful of delayed init retries with increasing backoff if the container isn't ready yet. It sounds like overkill until you get a real user's bug report that just says "the map is gray on my phone."
The service worker: what to cache, and what not to#
@ducanh2912/next-pwa is a maintained fork of the original next-pwa package that generates a Workbox-based service worker on top of next build. The key decision is to not cache everything with one strategy, but split resources by their nature:
// next.config.ts
import withPWAInit from '@ducanh2912/next-pwa';
const withPWA = withPWAInit({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
cacheOnFrontEndNav: true,
fallbacks: { document: '/_offline' },
workboxOptions: {
runtimeCaching: [
{
// Map tiles are the heaviest and most stable resource: the same
// tile almost never changes, so CacheFirst is the right call.
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'osm-tiles',
expiration: {
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ request }) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: { cacheName: 'pages', networkTimeoutSeconds: 10 },
},
],
},
});
export default withPWA({
output: 'standalone',
});Tiles get CacheFirst with a hard maxEntries cap — without a limit, the tile cache grows without bound, because exploring a city can pull in thousands of unique tiles in a single session. Pages get NetworkFirst with a timeout: if the network is alive, content refreshes; if not, the service worker serves the last cached version within 10 seconds. A separate fallbacks.document points to an offline page — the /_offline route, which must exist in the app and depend on no server data whatsoever.
It's worth explaining why @ducanh2912/next-pwa specifically, rather than the original next-pwa package by shadowwalker: the latter is effectively unmaintained and doesn't play well with the App Router or recent Next.js versions, while the fork actively fixes exactly those conflicts. There's one more detail almost everyone trips on: the generated service worker is disabled in development by default (disable: process.env.NODE_ENV === 'development') — offline mode simply cannot be tested against local next dev, only against a built next build && next start or production. Testing offline mode means the Application → Service Workers tab in DevTools with the "Offline" toggle, not just switching off Wi-Fi — the browser can serve a page from the ordinary HTTP cache and create the illusion of a working offline mode where the service worker never actually participated.
Client-side state: Zustand instead of a backend#
The architectural decision that makes offline mode trivial rather than heroic: the app has no backend for its core flow. The route, its points, the activity type — all of it is state in Zustand, synced to localStorage. There's no offline sync queue to reason about, no version conflicts to resolve — because there's nothing to synchronize.
// stores/routeStore.ts
import { create } from 'zustand';
interface RoutePoint {
lat: number;
lng: number;
elevation: number | null;
}
interface RouteState {
points: RoutePoint[];
addPoint: (point: RoutePoint) => void;
removePoint: (index: number) => void;
undoLastPoint: () => void;
clearRoute: () => void;
}
// The route lives only in the browser: no backend, no sessions,
// no risk of losing points on a dropped connection — state is local.
export const useRouteStore = create<RouteState>((set) => ({
points: [],
addPoint: (point) =>
set((state) => ({ points: [...state.points, point] })),
removePoint: (index) =>
set((state) => ({
points: state.points.filter((_, i) => i !== index),
})),
undoLastPoint: () =>
set((state) => ({ points: state.points.slice(0, -1) })),
clearRoute: () => set({ points: [] }),
}));The app's one network dependency is an external routing service that snaps the route to roads instead of cutting straight through them. It isn't critical: if the request fails or is unreachable, the app draws a dashed straight line between the points and honestly computes distance with the haversine formula, instead of showing an error and a blank screen. That's the difference between degrading gracefully and failing outright.
GPX and elevation profiles with no server#
Exporting a track as GPX needs neither a server nor temporary files — the format is simple enough to assemble as an XML string right in the browser and hand it off via Blob and URL.createObjectURL:
// lib/gpx/export.ts
interface TrackPoint {
lat: number;
lng: number;
elevation: number | null;
}
// GPX is assembled as a string on the client — no API route, no server.
export function buildGpx(points: TrackPoint[], name: string): string {
const trkpts = points
.map((p) => {
const ele = p.elevation !== null ? `<ele>${p.elevation}</ele>` : '';
return `<trkpt lat="${p.lat}" lon="${p.lng}">${ele}</trkpt>`;
})
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="RunCalculator Pro">
<trk>
<name>${name}</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>`;
}
export function downloadGpx(xml: string, filename: string) {
const blob = new Blob([xml], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}This works offline for exactly one reason: it makes zero network requests. The data is already sitting in the tab's memory, and Blob is a browser mechanism that needs no server, even to download a file.
PWAs on iOS in 2026: what actually works#
Designing offline mode for Android and never checking it on an iPhone is a guaranteed way to collect "doesn't work on iPhone" tickets. Safari's constraints haven't disappeared, but they've shifted:
| Capability | iOS Safari (2026) | Android Chrome |
|---|---|---|
| Add to Home Screen | Works as a WebClip, not a full PWA container | Full install, separate process |
| Cache Storage quota | Roughly 50 MB per origin, can be purged after long inactivity | Effectively unbounded (hundreds of MB and beyond) |
| Background service worker | Runs, but with a shortened process lifetime | Runs reliably, including Background Sync |
| Push notifications | Available since iOS 16.4+ (outside the EU); Safari 18.4 added Declarative Web Push | Full support for years already |
| Standalone web-app mode | iOS 26 defaults to it for sites added to the Home Screen | Controlled via display in the manifest |
The practical takeaway for a map: the maxEntries: 1000 cap in the service worker config isn't just disk hygiene, it's direct protection against iOS wiping the cache entirely if the user hasn't opened the app in a few weeks. Offline mode on iOS is best designed as "an enhancement for active users," not a guarantee — and it must be tested on a real iPhone, not just the simulator, because some WebClip behavior the simulator simply doesn't reproduce.
One more detail that's easy to miss: starting with iOS 26, a site added to the Home Screen opens in standalone web-app mode by default — no Safari address bar — where it used to require explicitly declaring apple-mobile-web-app-capable. That's good news for a PWA feeling like a "real" app, but it also means you now own back navigation and system gesture handling that the browser chrome used to partly cover for you. For a map specifically, that means a "back" affordance out of fullscreen mode and correct safe-area-inset handling on notched devices.
Wrapping up: a pre-production checklist#
Before calling an app an offline PWA, it's worth running down a short list:
- Leaflet, and anything else touching
window, is imported only inside a'use client'component wrapped indynamic(..., { ssr: false }). - Map tiles are cached with
CacheFirstand an explicitexpiration.maxEntries— without a cap, the cache grows unchecked. fallbacks.documentis configured, and the offline page has actually been tested in DevTools' "Offline" mode, not just assumed to work.- The core user flow doesn't depend on the server: state lives in Zustand/
localStorage, not a backend session. - Any external API (routing, elevation) has a clear fallback instead of a blank screen on network failure.
- Behavior has been verified on a real iPhone: cache quota, WebClip behavior, and service worker lifecycle differ from Android and from desktop Chrome.
RunCalculator Pro has been through every item on this list — not as a teaching example, but as a working product at runcalculator.pro: the map, offline mode, local state, and GPX export aren't a hypothesis, they're what's already running for real users. The app is entirely free, with no sign-up and no backend for its core flow — and that isn't a marketing line, it's a direct consequence of the architecture above: when state has nowhere to sync except the user's own browser, offline mode stops being a feature you "add" and becomes a side effect of correctly separating client and server responsibilities from the start.



