Why such fucking waste?
Next.jsアプリにPWA(Progressive Web Apps)を機能追加した際に、問題がありました。iPhone環境で画面遷移した際にURLバーが出てきてしまい、画面遷移でリロードを行うとダメなのでしょう。
単純に考えてSPA(Single-page application)にすれば解決しそうですが、Next.jsを採用しているため、全体をSPAにするというのは、non senseです。
そのため、Next.jsのApp RouterとReact Routerを組み合わせて部分的にSPAにする構成に変更しました。この記事の方法を採用すればMPAとSPAを自由に組み合わせられるのでNext.jsでの自由度が高まりそうです。
環境
- Vercel CLI 41.1.3
- Next.js: 14.2.15
- next-pwa: 5.6.0
- react-router-dom: 7.1.3
- iOS: 18.1.1
導入手順
1.諸々インストール
# Next.jsアプリを新規作成
npx create-next-app@latest
# PWAライブラリ
npm install next-pwa
# SPAのルーター
npm install react-router-dom
2.ルーティング
前提として、ルート配下は通常のApp Routerとなります。
クライアントサイドルーティング
SPAのルートパス(今回は/app/pwa)にReact Routerを展開します。
"use client";
import { Route, BrowserRouter as Router, Routes } from "react-router-dom";
// ...
const PWARouter = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Hydrationエラーになるのでマウント前は描画しない
if (!mounted) {
return null;
}
return (
<Router>
<Routes>
<Route path="/pwa/" element={<Menu Header={SPAHeader} />}/>
<Route path="/pwa/feat1/" element={<Feat1 Header={SPAHeader} />}/>
<Route path="/pwa/feat2/" element={<Feat2 Header={SPAHeader} />}/>
</Routes>
</Router>
);
};
export default PWARouter;
propsで渡しているHeaderコンポーネントは、SSGではuseRouter
、SPAではuseNavigate
をそれぞれラップしたコンポーネントです。
Routerが別物となるので、フックを呼び出した時点でエラーを吐きます。MPAとSPAでコードを切り分ける場合はコンポーネントレベルで分離が必要となります。
サーバーサイドルーティング
const nextConfig = {
async rewrites() {
// ブラウザのURLは変わらず、/pwa/にルーティングされる
return [{ source: "/pwa/(.*)", destination: "/pwa/" }];
},
};
/pwa配下の各ページはNext.jsから見ると何も存在しないため、App Routerに対して/pwa/feat1のリクエストが飛んできた際、デフォルトではエラーページを返します。
このため、サーバ側のルーティング設定で/pwa/...以下のリクエストをいったん/pwaに当てる処理が必要となります。
rewrites
オプションを付与することで、上記の動きを実現できます。rewrites
は、ブラウザから見えるパスとサーバーが返すページを自由に設定するためのオプションです。
サーバーからページコンテンツを受け取った後で、ブラウザ側でURLが評価されてルーティングが実行される流れです。
3.Service Worker
Service Workerはスクリプトをサブスレッドで実行するための仕組みです。プッシュ通知やバックグラウンドの非同期処理など、通常のウェブアプリケーションでは扱えない低レイヤーの処理を実装できるようになります。PWAではキャッシュの管理が主な役割となります。
Service Workerはjsをそのまま書くこともできますが、キャッシュ管理は初心者には難解なため、ライブラリを使うことをおすすめします。今回はnext-pwaを採用しています。
/** @type {import('next').NextConfig} */
import nextPWA from "next-pwa";
const withPWA = nextPWA({
dest: "public",
register: true,
skipWaiting: true,
// ローカルではwarningを吐くのでnext-pwaを無効にします
disable: process.env.NODE_ENV === "development",
});
const nextConfig = withPWA({
reactStrictMode: true,
trailingSlash: true,
async rewrites() {
return [{ source: "/pwa/(.*)", destination: "/pwa/" }];
},
});
export default nextConfig;
next-pwaがビルド毎にjsを生成するのでignoreしておきます。
# next-pwa
**/public/sw.js
**/public/sw.js.map
**/public/workbox-*.js
**/public/workbox-*.js.map
4.その他PWAの設定
manifest.json
- name: ホーム画面に表示されるアプリの名前です
- start_url: PWAで初期設定されるURL
- icons: 配列中の一番近いサイズが自動で使われます
- display: standaloneに設定するとウインドウがブラウザと別になり、URLバーも非表示になります
- theme_color: URLバーの色指定
- background_color: コンテンツ背景の色指定
{
"name": "sample-app",
"short_name": "sample-app",
"start_url": "/pwa/",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#2563eb",
"background_color": "#2563eb",
"display": "standalone"
}
headタグ
以下のmetaタグが重要です。iPhoneなど一部のプラットフォームでPWAとしてインストールしたアプリをフルスクリーンで開かせるには、manifestの設定だけでは不十分です。
<head>
<link rel="manifest" href="/manifest.json" />
{/*iOS用*/}
<meta name="apple-mobile-web-app-capable" content="yes" />
{/*Android用*/}
<meta name="mobile-web-app-capable" content="yes" />
</head>
キャッシュコントロール
Next.jsのmiddleware
という機能を使います。middleware
とは、Next.jsにリクエストが処理される前に実行されるレイヤーです。プロキシに似た役割ですね。
next.config.mjsでも静的なキャッシュ管理は可能ですが、動的なキャッシュ管理をしたい場合はmiddleware
が適切です。
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(_request: NextRequest) {
const res = NextResponse.next();
res.headers.set("Cache-Control", "public, max-age=31536000, immutable");
return res;
}
export const config = {
matcher: ["/pwa/:path*", "/favicon.ico"],
};
デプロイ
最後にVercelにデプロイしてください。
ローカルでは基本的に動かないと思います。