オフラインPWAと地図:Next.js×Leaflet実践#
Next.jsのPWAチュートリアルの多くは、TODOリストやニュースフィードどまりだ。テキストはキャッシュが簡単で、「オフライン対応」は「すでに読み込んだものを表示する」だけで済んでしまう。インタラクティブな地図はまったく別次元の難しさを持つ。Leafletはモジュールをインポートした瞬間にwindowとdocumentに触れる。地図タイルは数十メガバイトに達しiOSのCache Storageの上限をあっさり超える。そしてルートの状態は、森の中を走っていて電波が完全にない状況でも壊れずに残らなければならない。
私はこの一連の課題を、無料のランニングルートプランナーRunCalculator Pro(runcalculator.pro)の開発で実際に経験した。地図をクリックするとルートが構築され、標高プロファイル、カロリー・歩数・ペース・タイムが計算される。目標距離を指定してランダムなルートを生成することもでき、GPX形式でエクスポートできる。すべてクライアント側だけで動作し、アプリとしてインストールでき、ネットワークがなくても動き続ける。以下は「PWAは素晴らしい」という一般論ではなく、具体的な技術的判断の記録だ。
オフライン地図がまれで難しいケースである理由#
典型的なPWAには明確なデータソースが1つある——キャッシュすべきAPIだ。地図には3つある。サーバーレンダリングに絶対混ざってはいけないLeafletのJSバンドル自体。1画面あたり数千件の小さなPNG/WebPリクエストになるOpenStreetMapのタイル。そしてルートを形作り、ローカルに永続化されなければならないユーザー入力(クリック)。この3つはそれぞれ違う壊れ方をする——事前にアーキテクチャを設計せず、後から場当たり的に直そうとした場合には。
LeafletをNext.jsに移植したほぼ全員がぶつかるエラーがある。ビルド時か最初のサーバーレンダリングの時点でReferenceError: window is not definedが発生し、ライブラリが落ちるのだ。これはLeafletのバグではない——最初からブラウザ向けに書かれており、その依存関係を隠していないだけだ。react-leafletにも似た問題がある。dynamicでラップしていても、MapContainerコンポーネントはアンマウント→再マウント(たとえばアプリ内タブを素早く切り替えた場合)の際に、コンテナ再初期化エラーを投げることがある。Leafletが内部状態をDOMノードそのものに保持しているためだ。クリック、マーカーのドラッグ、ラベル用のカスタムペインなど、操作が激しい地図では、react-leafletのJSXラッパーよりも、Leaflet自体の命令的APIを直接使うほうがシンプルで予測しやすいことが多い。react-leafletはシンプルな静的地図向けと割り切るのが現実的だ。
SSRとLeaflet:「window is not defined」の回避方法#
対処法自体はNext.jsの定石だが、一つ注意点がある。ssr: falseは、Leafletのpropsを消費するだけのコンポーネントではなく、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アニメーション直後に地図が開く場合、初期化の時点でコンテナの高さがゼロになりうる。実務で効くのは、1フレームに頼らずResizeObserverをコンテナに設置してサイズ変化のたびにinvalidateSize()を呼び直し、さらにコンテナがまだ準備できていない場合に備えて遅延を増やしながら初期化を数回リトライすることだ。過剰防衛に見えるかもしれないが、実際のユーザーから「スマホで地図が灰色のまま」というバグ報告を受け取るまでは、そう感じているものだ。
Service Worker:何をキャッシュし、何をキャッシュしないか#
@ducanh2912/next-pwaは、オリジナルのnext-pwaパッケージのメンテナンスされているフォークで、next buildの上にWorkboxベースのService Workerを生成する。重要なのは、すべてを1つの戦略でキャッシュするのではなく、リソースの性質ごとに分けることだ。
// 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',
});タイルは厳格なmaxEntries付きのCacheFirstにする——上限がなければ、街を探索するだけで1セッションに数千枚のユニークなタイルが読み込まれ、キャッシュが際限なく肥大化する。ページはNetworkFirstにタイムアウトを付ける。ネットワークが生きていればコンテンツは更新され、そうでなければService Workerが10秒以内に最後にキャッシュされたバージョンを返す。別途fallbacks.documentがオフラインページ——サーバーのデータに一切依存してはいけない/_offlineルート——を指す。
なぜshadowwalkerによるオリジナルのnext-pwaではなく@ducanh2912/next-pwaを選んだのかにも触れておく価値がある。前者は実質的にメンテナンスされておらず、App Routerや最近のNext.jsバージョンとの相性が悪い。一方このフォークはまさにその衝突を積極的に修正している。もう一つ、ほぼ全員がつまずくポイントがある。生成されるService Workerは開発モードではデフォルトで無効になっている(disable: process.env.NODE_ENV === 'development')——オフライン動作はローカルのnext devでは物理的に検証できず、ビルド済みのnext build && next startか本番環境でしか確認できない。オフライン動作の検証は、Wi-Fiを切るだけではなく、DevToolsのApplication→Service Workersタブの「Offline」トグルで行う必要がある。単にWi-Fiを切っただけでは、ブラウザが通常のHTTPキャッシュからページを返してしまい、Service Workerが実際には一切関与していないのに、オフラインが機能しているような錯覚を生むことがある。
クライアント側の状態管理:バックエンドの代わりに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はファイルのダウンロードにさえサーバーを必要としないブラウザの仕組みだ。
2026年のiOSにおけるPWA:実際に動くもの#
Androidだけを想定してオフライン対応を設計し、iPhoneで一度も確認しない——これは「iPhoneで動かない」というチケットを確実に量産する方法だ。Safariの制約は消えてはいないが、内容は変わってきている。
| 機能 | iOS Safari(2026) | Android Chrome |
|---|---|---|
| ホーム画面に追加 | WebClipとして動作し、完全なPWAコンテナではない | 完全なインストール、独立プロセス |
| Cache Storageの上限 | オリジンあたり約50MB、長期間未使用だと削除されることがある | 実質無制限(数百MB以上) |
| バックグラウンドのService Worker | 動作するが、プロセスの生存時間が短い | Background Syncを含め安定して動作 |
| プッシュ通知 | iOS 16.4以降(EU域外)で利用可能、Safari 18.4でDeclarative Web Pushが追加 | 数年前から完全対応 |
| 独立ウェブアプリモード | iOS 26からホーム画面追加サイトでデフォルトで有効 | manifestのdisplayで制御 |
地図に関する実務上の結論はこうだ。Service Worker設定のmaxEntries: 1000という上限は、単なるディスクの衛生管理ではなく、ユーザーが数週間アプリを開かなかった場合にiOSがキャッシュを丸ごと消してしまうことへの直接的な防御でもある。iOSでのオフライン対応は「保証」ではなく「アクティブなユーザー向けの底上げ」として設計するのが現実的だ。そして必ず実機のiPhoneでテストする必要がある——シミュレーターでは再現されないWebClipの挙動があるからだ。
見落としがちな点がもう一つある。iOS 26からは、ホーム画面に追加されたサイトはデフォルトで独立ウェブアプリモードで開く——Safariのアドレスバーなしで。以前はapple-mobile-web-app-capableを明示的に指定する必要があった。PWAが「本物の」アプリらしく感じられるという意味では朗報だが、同時に、これまでブラウザのUIが部分的に肩代わりしていた戻る操作やシステムジェスチャーの処理を、自分たちで面倒を見る必要が出てきたということでもある。地図に限って言えば、フルスクリーン表示からの「戻る」導線と、ノッチのあるデバイスでのsafe-area-insetの正しい扱いがこれに当たる。
まとめ:本番投入前のチェックリスト#
アプリを「オフラインPWA」と呼ぶ前に、短いリストを確認しておく価値がある。
- Leafletや
windowに触れるものは、dynamic(..., { ssr: false })でラップされた'use client'コンポーネントの内側でのみインポートする。 - 地図タイルは
CacheFirstと明示的なexpiration.maxEntriesでキャッシュする——上限がなければキャッシュは際限なく肥大化する。 fallbacks.documentを設定し、オフラインページはDevToolsの「Offline」モードで実際に検証済みであり、机上の想定で済ませない。- 主要なユーザーフローはサーバーに依存しない——状態はバックエンドのセッションではなくZustand/
localStorageに置く。 - 外部API(ルーティング、標高)には、ネットワーク障害時に空白画面ではなく明確なフォールバックがある。
- 実機のiPhoneで動作を検証済み——キャッシュ上限、WebClipの挙動、Service Workerのライフサイクルは、Androidやデスクトップ版Chromeとは異なる。
RunCalculator Proはこのリストの全項目を、教材としてではなくruncalculator.proで稼働している実際のプロダクトとして通過している。地図、オフライン対応、ローカル状態、GPXエクスポートは仮説ではなく、すでに実際のユーザーに使われているものだ。このアプリは完全無料で、登録不要、コアフローにバックエンドを持たない——これはマーケティング文句ではなく、上で述べたアーキテクチャの直接の帰結だ。状態がユーザー自身のブラウザ以外に同期する先を持たないなら、オフライン対応は「後から追加する機能」ではなくなり、最初からクライアントとサーバーの責務を正しく分離したことの副産物になる。



