Офлайн-PWA с интерактивными картами: Next.js и Leaflet#
Большинство статей про PWA на Next.js ограничиваются списком дел или новостной лентой: текст легко закэшировать, а офлайн-режим сводится к «показать то, что уже загружено». Интерактивная карта — совсем другой уровень сложности. Leaflet обращается к window и document в момент импорта модуля, тайлы карты весят десятки мегабайт и не помещаются в квоту Cache Storage на iOS, а состояние маршрута должно переживать полную потерю сети посреди пробежки в лесу, где связи нет вообще.
Я прошёл этот путь при разработке RunCalculator Pro — бесплатного планировщика беговых маршрутов на runcalculator.pro. Кликаешь по карте — приложение строит маршрут, профиль высот, считает калории, шаги, темп и время, умеет сгенерировать случайный маршрут на заданную дистанцию и экспортировать всё в GPX. Всё это работает полностью на клиенте, устанавливается как приложение и продолжает работать без сети. Ниже — конкретные технические решения, а не общие рассуждения о том, что «PWA — это круто».
Почему офлайн-карта — редкий и сложный кейс#
У обычного PWA есть один явный источник данных — API, который нужно закэшировать. У карты источников три: сам JS-бандл Leaflet (который не должен попасть в серверный рендеринг), тайлы OpenStreetMap (тысячи мелких PNG/WebP-запросов на один экран) и пользовательский ввод (клики, которые формируют маршрут и должны сохраняться локально). Каждый из трёх источников ломается по-своему, если не спроектировать архитектуру заранее — а не патчить постфактум.
Ошибка, с которой сталкивается почти каждый, кто переносит Leaflet в Next.js: библиотека падает уже на этапе сборки или первого серверного рендера с ошибкой ReferenceError: window is not defined. Это не баг Leaflet — она изначально писалась для браузера и не скрывает эту зависимость. Похожая ситуация с react-leaflet: даже если обернуть в dynamic, компонент MapContainer при размонтировании и повторном монтировании (например, при быстрой навигации между вкладками приложения) иногда падает с ошибкой повторной инициализации контейнера, потому что Leaflet хранит служебное состояние прямо в DOM-узле. Поэтому для карты с интенсивным взаимодействием — кликами, перетаскиванием маркеров, кастомными панами для подписей — часто проще и предсказуемее работать с императивным API самого Leaflet напрямую, а не через JSX-обёртки react-leaflet, оставляя последний только для простых статических карт.
SSR и Leaflet: как обойти «window is not defined»#
Решение стандартное для Next.js, но с одним нюансом: ssr: false должен оборачивать компонент, который импортирует Leaflet, а не просто использует его пропсы. Если импорт leaflet стоит на верхнем уровне файла, который случайно попадает в серверный граф зависимостей, ошибка вылезет ещё до того, как отработает dynamic().
// 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 — отдельный файл с 'use client' наверху, где Leaflet уже безопасно импортировать напрямую:
// 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 }} />;
}На практике одного requestAnimationFrame не всегда достаточно: если карта открывается внутри разворачивающейся панели или сразу после CSS-анимации, контейнер в момент инициализации может иметь нулевую высоту. Рабочий приём — не полагаться на один кадр, а ставить ResizeObserver на контейнер и пересчитывать invalidateSize() при каждом изменении размеров, плюс несколько отложенных попыток инициализации с нарастающей задержкой, если контейнер ещё не готов. Звучит как избыточная защита, пока не увидишь баг-репорт «карта серая на телефоне» от реального пользователя.
Сервис-воркер: что кэшировать, а что нет#
@ducanh2912/next-pwa — поддерживаемый форк оригинального next-pwa, который генерирует сервис-воркер на Workbox поверх next build. Ключевое решение — не кэшировать всё одной стратегией, а развести ресурсы по их природе:
// 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',
});Тайлы получают CacheFirst с жёстким maxEntries — без лимита кэш тайлов растёт неограниченно, потому что при исследовании города пользователь может подгрузить тысячи уникальных плиток за один сеанс. Страницы получают NetworkFirst с таймаутом: если сеть жива, контент обновляется; если нет — за 10 секунд сервис-воркер отдаёт последнюю закэшированную версию. Отдельный fallbacks.document указывает на офлайн-страницу — маршрут /_offline, который должен существовать в приложении и не зависеть ни от каких данных с сервера.
Стоит отдельно упомянуть, почему выбран именно @ducanh2912/next-pwa, а не оригинальный пакет next-pwa от shadowwalker: тот фактически не поддерживается и плохо совместим с App Router и последними версиями Next.js, тогда как форк активно чинит именно эти конфликты. И ещё одна деталь, о которую спотыкаются почти все: сгенерированный сервис-воркер по умолчанию отключён в режиме разработки (disable: process.env.NODE_ENV === 'development') — офлайн-режим физически невозможно проверить локальным next dev, только на собранном next build && next start или в проде. Проверять офлайн-режим нужно через вкладку Application → Service Workers в DevTools с переключателем «Offline», а не одним лишь выключением Wi-Fi — браузер может обслужить страницу из обычного HTTP-кэша и создать иллюзию рабочего офлайн-режима там, где сервис-воркер на самом деле не участвовал.
Состояние на клиенте: Zustand вместо бэкенда#
Ключевое архитектурное решение, которое делает офлайн-режим тривиальным, а не героическим: у приложения нет бэкенда для основного сценария. Маршрут, точки, тип активности — всё это состояние в Zustand, которое синхронизируется с localStorage. Не нужно думать про офлайн-очередь синхронизации или конфликты версий — потому что синхронизировать нечего.
// 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: [] }),
}));Единственная сетевая зависимость приложения — внешний роутинг-сервис, который прокладывает маршрут по дорогам, а не напролом. Он не критичен: если запрос падает или недоступен, приложение рисует прямую линию между точками пунктиром и честно считает дистанцию по формуле гаверсинуса, вместо того чтобы показать пользователю ошибку и пустой экран. Это разница между «деградация» и «отказ».
GPX и профиль высот без сервера#
Экспорт трека в GPX не требует ни сервера, ни временных файлов — формат достаточно прост, чтобы собрать XML строкой прямо в браузере и отдать его через Blob и 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);
}Это работает офлайн ровно потому, что не делает ни одного сетевого запроса: данные уже в памяти вкладки, а Blob — механизм браузера, не требующий сервера даже для скачивания файла.
PWA на iOS в 2026: что реально работает#
Планировать офлайн-режим под Android и не проверять его на iPhone — гарантированный способ получить тикеты «на айфоне не работает». Ограничения Safari не исчезли, но изменились:
| Возможность | iOS Safari (2026) | Android Chrome |
|---|---|---|
| Установка на экран «Домой» | Работает как WebClip, не полноценный PWA-контейнер | Полноценная установка, отдельный процесс |
| Квота Cache Storage | Около 50 МБ на origin, может очищаться при долгом простое | Практически не ограничена (сотни МБ и больше) |
| Сервис-воркер в фоне | Работает, но с урезанным временем жизни процесса | Работает стабильно, включая Background Sync |
| Push-уведомления | Есть с iOS 16.4+ (вне ЕС); Safari 18.4 добавил Declarative Web Push | Полная поддержка уже несколько лет |
| Режим отдельного веб-приложения | iOS 26 включает по умолчанию для сайтов, добавленных на «Домой» | Управляется через display в манифесте |
Практический вывод для карты: лимит maxEntries: 1000 в конфиге сервис-воркера — это не только гигиена диска, а прямая защита от того, что iOS может целиком вычистить кэш, если пользователь не открывал приложение несколько недель. Офлайн-режим на iOS стоит проектировать как «улучшение для активных пользователей», а не как гарантию — и обязательно тестировать на реальном iPhone, а не только в симуляторе, потому что часть поведения WebClip симулятор не воспроизводит.
Ещё один нюанс, который легко упустить: с iOS 26 сайт, добавленный на «Домой», по умолчанию открывается в режиме отдельного веб-приложения — без адресной строки Safari, как раньше требовалось явно указывать через apple-mobile-web-app-capable. Это хорошая новость для восприятия PWA как «настоящего» приложения, но она же означает, что нужно самостоятельно предусмотреть навигацию назад и обработку системных жестов, которые раньше частично брал на себя браузерный chrome. Для карты это конкретно — кнопка «назад» из полноэкранного режима карты и корректная работа safe-area-inset на устройствах с вырезом.
Итоги: чек-лист перед продакшеном#
Перед тем как называть приложение офлайн-PWA, стоит пройтись по короткому списку:
- Leaflet и любые библиотеки, трогающие
window, импортируются только внутри'use client'-компонента, обёрнутого вdynamic(..., { ssr: false }). - Тайлы карты кэшируются стратегией
CacheFirstс явнымexpiration.maxEntries— без лимита кэш растёт неконтролируемо. - Настроен
fallbacks.documentи офлайн-страница реально протестирована в режиме «Offline» в DevTools, а не только в теории. - Основной пользовательский сценарий не зависит от сервера: состояние живёт в Zustand/
localStorage, а не в сессии на бэкенде. - Любой внешний API (роутинг, высоты) имеет понятный fallback вместо пустого экрана при сбое сети.
- Поведение проверено на реальном iPhone: квота кэша, поведение WebClip и жизненный цикл сервис-воркера на iOS отличаются от Android и от desktop-Chrome.
RunCalculator Pro прошёл через все эти пункты не как учебный пример, а как рабочий продукт на runcalculator.pro: карта, офлайн-режим, локальное состояние и экспорт GPX — не гипотеза, а то, что уже работает у реальных пользователей. Приложение полностью бесплатно, без регистрации и без бэкенда для основного сценария — это не маркетинговая формулировка, а прямое следствие архитектуры, описанной выше: если состоянию некуда синхронизироваться, кроме браузера пользователя, то офлайн-режим перестаёт быть отдельной фичей, которую нужно «добавить», и становится побочным эффектом изначально верного разделения ответственности между клиентом и сервером.



