PWA offline con mapas interactivos: Next.js y Leaflet#
La mayoría de los tutoriales de PWA en Next.js se quedan en una lista de tareas o un feed de noticias: el texto es trivial de cachear, y el "modo offline" se reduce a "mostrar lo que ya se cargó". Un mapa interactivo es un nivel de dificultad completamente distinto. Leaflet toca window y document en el momento mismo en que se importa el módulo, las teselas del mapa pesan decenas de megabytes y superan la cuota de Cache Storage en iOS, y el estado de la ruta debe sobrevivir a una pérdida total de conectividad en medio de una carrera por el bosque, donde no hay señal en absoluto.
Recorrí todo este camino construyendo RunCalculator Pro — un planificador de rutas de running gratuito en runcalculator.pro. Haces clic en el mapa y la app construye la ruta, el perfil de elevación, y calcula calorías, pasos, ritmo y tiempo; puede generar una ruta aleatoria para una distancia objetivo y exportar todo como GPX. Todo funciona íntegramente en el cliente, se instala como app y sigue funcionando sin red. Lo que sigue son decisiones técnicas concretas, no un discurso genérico sobre lo geniales que son las PWA.
Por qué un mapa offline es un caso raro y difícil#
Una PWA típica tiene una fuente de datos evidente — una API que hay que cachear. Un mapa tiene tres. Está el propio bundle JS de Leaflet, que nunca debe terminar en el renderizado del servidor. Están las teselas de OpenStreetMap — miles de peticiones PNG/WebP pequeñas para una sola pantalla. Y está la entrada del usuario — clics que forman una ruta y deben persistir localmente. Cada una de las tres se rompe a su manera si la arquitectura no se diseña de antemano, en lugar de parchearla después.
El error con el que se topa casi todo el que porta Leaflet a Next.js: la biblioteca falla ya en el build o en el primer renderizado del servidor con ReferenceError: window is not defined. No es un bug de Leaflet — se escribió para el navegador desde el primer día y nunca oculta esa dependencia. react-leaflet tiene un problema parecido: incluso envuelto en dynamic, el componente MapContainer a veces lanza un error de reinicialización al desmontar y volver a montar (por ejemplo, al navegar rápido entre pestañas de la app), porque Leaflet guarda estado interno directamente en el nodo del DOM. Para un mapa con interacción intensa — clics, marcadores arrastrables, panes personalizados para etiquetas — suele ser más simple y predecible trabajar directamente con la API imperativa del propio Leaflet, en lugar de los envoltorios JSX de react-leaflet, dejando este último solo para mapas estáticos simples.
SSR y Leaflet: cómo evitar "window is not defined"#
La solución es estándar en Next.js, con un matiz: ssr: false debe envolver el componente que importa Leaflet, no simplemente uno que consume sus props. Si el import de leaflet está en el nivel superior de un archivo que termina, sin querer, en el grafo de dependencias del servidor, el error aparece antes de que dynamic() llegue siquiera a actuar.
// 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 en sí es un archivo aparte con 'use client' arriba, donde ya es seguro importar Leaflet directamente:
// 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 }} />;
}En la práctica, un solo requestAnimationFrame no siempre basta: si el mapa se abre dentro de un panel que se despliega, o justo después de una animación CSS, el contenedor puede tener altura cero en el momento de la inicialización. El truco que sí funciona es no depender de un único frame — poner un ResizeObserver en el contenedor y recalcular invalidateSize() en cada cambio de tamaño, más varios reintentos de inicialización con retraso creciente si el contenedor aún no está listo. Suena a protección excesiva hasta que recibes el reporte de un usuario real que dice, sin más, "el mapa se ve gris en mi teléfono".
El service worker: qué cachear y qué no#
@ducanh2912/next-pwa es un fork mantenido del paquete original next-pwa, que genera un service worker basado en Workbox sobre next build. La decisión clave es no cachear todo con una sola estrategia, sino separar los recursos según su naturaleza:
// 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',
});Las teselas reciben CacheFirst con un maxEntries estricto — sin límite, la caché de teselas crece sin control, porque explorar una ciudad puede traer miles de teselas únicas en una sola sesión. Las páginas reciben NetworkFirst con un timeout: si la red funciona, el contenido se actualiza; si no, en 10 segundos el service worker sirve la última versión cacheada. Un fallbacks.document aparte apunta a la página offline — la ruta /_offline, que debe existir en la app y no depender de ningún dato del servidor.
Vale la pena explicar por qué @ducanh2912/next-pwa en concreto, y no el paquete original next-pwa de shadowwalker: este último está prácticamente sin mantenimiento y no convive bien con el App Router ni con las versiones recientes de Next.js, mientras que el fork corrige activamente justo esos conflictos. Hay otro detalle con el que casi todos tropiezan: el service worker generado está desactivado en desarrollo por defecto (disable: process.env.NODE_ENV === 'development') — el modo offline sencillamente no se puede probar contra next dev local, solo contra un next build && next start ya compilado o en producción. Probar el modo offline significa la pestaña Application → Service Workers de DevTools con el interruptor "Offline", no simplemente apagar el Wi-Fi — el navegador puede servir la página desde la caché HTTP normal y crear la ilusión de un modo offline funcional donde el service worker en realidad nunca participó.
Estado en el cliente: Zustand en lugar de backend#
La decisión arquitectónica que hace que el modo offline sea trivial, y no heroico: la app no tiene backend para su flujo principal. La ruta, sus puntos, el tipo de actividad — todo es estado en Zustand, sincronizado con localStorage. No hay que pensar en una cola de sincronización offline ni en conflictos de versiones — porque no hay nada que sincronizar.
// 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: [] }),
}));La única dependencia de red de la app es un servicio de enrutamiento externo que ajusta la ruta a las calles en vez de cruzarlas en línea recta. No es crítico: si la petición falla o no está disponible, la app dibuja una línea recta punteada entre los puntos y calcula honestamente la distancia con la fórmula del haversine, en lugar de mostrar un error y una pantalla en blanco. Esa es la diferencia entre degradar con elegancia y simplemente fallar.
GPX y perfil de elevación sin servidor#
Exportar una ruta a GPX no necesita ni servidor ni archivos temporales — el formato es lo bastante simple como para ensamblarlo como una cadena XML directamente en el navegador y entregarlo con Blob y 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);
}Esto funciona offline por una razón muy concreta: no hace ni una sola petición de red. Los datos ya están en la memoria de la pestaña, y Blob es un mecanismo del navegador que no necesita servidor ni siquiera para descargar un archivo.
PWA en iOS en 2026: qué funciona de verdad#
Diseñar el modo offline pensando en Android y no probarlo nunca en un iPhone es una forma garantizada de acumular tickets de "no funciona en el iPhone". Las limitaciones de Safari no han desaparecido, pero han cambiado:
| Capacidad | iOS Safari (2026) | Android Chrome |
|---|---|---|
| Añadir a pantalla de inicio | Funciona como WebClip, no como contenedor PWA completo | Instalación completa, proceso independiente |
| Cuota de Cache Storage | Unos 50 MB por origen, puede purgarse tras inactividad prolongada | Prácticamente sin límite (cientos de MB o más) |
| Service worker en segundo plano | Funciona, pero con un tiempo de vida del proceso reducido | Funciona de forma estable, incluyendo Background Sync |
| Notificaciones push | Disponibles desde iOS 16.4+ (fuera de la UE); Safari 18.4 añadió Declarative Web Push | Soporte completo desde hace años |
| Modo de app web independiente | iOS 26 lo activa por defecto para sitios añadidos a la pantalla de inicio | Se controla vía display en el manifest |
La conclusión práctica para un mapa: el límite maxEntries: 1000 en la configuración del service worker no es solo higiene de disco, es una protección directa contra que iOS borre la caché entera si el usuario no abre la app durante varias semanas. El modo offline en iOS conviene diseñarlo como "una mejora para usuarios activos", no como una garantía — y hay que probarlo en un iPhone real, no solo en el simulador, porque parte del comportamiento del WebClip el simulador simplemente no lo reproduce.
Otro detalle fácil de pasar por alto: desde iOS 26, un sitio añadido a la pantalla de inicio se abre por defecto en modo de app web independiente — sin la barra de direcciones de Safari — donde antes hacía falta declarar explícitamente apple-mobile-web-app-capable. Es una buena noticia para que la PWA se sienta como una app "de verdad", pero también significa que ahora hay que encargarse tú mismo de la navegación hacia atrás y de los gestos del sistema que antes cubría en parte el chrome del navegador. Para un mapa en concreto, eso significa un botón de "atrás" saliendo del modo pantalla completa y un manejo correcto de safe-area-inset en dispositivos con notch.
En resumen: checklist antes de producción#
Antes de llamar a una app "PWA offline", conviene repasar una lista corta:
- Leaflet, y cualquier cosa que toque
window, se importa solo dentro de un componente'use client'envuelto endynamic(..., { ssr: false }). - Las teselas del mapa se cachean con
CacheFirsty unexpiration.maxEntriesexplícito — sin límite, la caché crece sin control. fallbacks.documentestá configurado, y la página offline se ha probado de verdad en modo "Offline" de DevTools, no solo se asume que funciona.- El flujo principal del usuario no depende del servidor: el estado vive en Zustand/
localStorage, no en una sesión de backend. - Cualquier API externa (enrutamiento, elevación) tiene un fallback claro en vez de una pantalla en blanco cuando falla la red.
- El comportamiento se ha verificado en un iPhone real: la cuota de caché, el comportamiento del WebClip y el ciclo de vida del service worker difieren de Android y de Chrome de escritorio.
RunCalculator Pro ha pasado por cada punto de esta lista — no como ejemplo didáctico, sino como producto en funcionamiento en runcalculator.pro: el mapa, el modo offline, el estado local y la exportación a GPX no son una hipótesis, son lo que ya funciona para usuarios reales. La app es completamente gratuita, sin registro y sin backend para su flujo principal — y eso no es una frase de marketing, es consecuencia directa de la arquitectura descrita arriba: cuando el estado no tiene dónde sincronizarse salvo el propio navegador del usuario, el modo offline deja de ser una función que hay que "añadir" y pasa a ser un efecto secundario de haber separado bien, desde el principio, las responsabilidades entre cliente y servidor.



