Next.js14でMPAとSPAを混在させてPWAを疎結合する

# 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](https://nextjs.org/docs/app)と[React Router](https://reactrouter.com/)を組み合わせて部分的に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を展開します。
``` page.tsx
"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でコードを切り分ける場合はコンポーネントレベルで分離が必要となります。

### サーバーサイドルーティング
```next.config.mjs
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を採用しています。

```next.config.mjs
/** @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しておきます。
```.gitignore
# 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: コンテンツ背景の色指定
```manifest.json
{
  "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の設定だけでは不十分です。
```layout.tsx
      <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`が適切です。

```middleware.ts
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](https://vercel.com/)にデプロイしてください。
ローカルでは基本的に動かないと思います。