Next.js PWA を @serwist/turbopack に移行してビルドを高速化する
目次
課題
Next.js に PWA 対応を後付けしたことがある人なら分かると思うが、Service Worker のツーリングはフレームワークと噛み合わないことが多い。このブログも例に漏れず、@serwist/next(webpack プラグイン)とカスタム esbuild スクリプトを組み合わせて SW をビルドしていたが、正直なところ壊れたまま放置していた部分があった。
具体的には以下の3つの問題を抱えていた。
- esbuild スクリプト(
scripts/build-sw.mjs)が precache manifest を注入しないため、プリキャッシュが実質的に機能していない - ビルドパイプラインが
velite build → esbuild → next buildの3段階で複雑 @serwist/nextが webpack 前提のため Turbopack に切り替えられない
@serwist/turbopack が webpack プラグインではなく Next.js Route Handler というまったく異なるアプローチを採用していると知り、興味が湧いて移行を試みた。
Route Handler アプローチが面白い理由
@serwist/turbopack で一番驚いたのは、バンドラーにまったくフックしない設計だったことだ。通常の Next.js Route Handler として実装され、ビルド時に esbuild で SW をコンパイルし、SSG で静的ファイルとして出力する。precache manifest の注入もこのプロセスの中で行われる。
SW を「ただのルート」として扱うことで、webpack か Turbopack かという問題自体を回避している。さらに Next.js のスタティック生成に乗るため、ソースマップや適切なキャッシュヘッダーも自然に手に入る。この設計はかなりエレガントだと感じた。
| 観点 | @serwist/next (webpack) | @serwist/turbopack |
|---|---|---|
| SW ビルド | webpack プラグインが自動生成 | Route Handler 経由で esbuild が処理 |
| SW の URL | /sw.js(public/ に出力) | /serwist/sw.js(Route Handler のパス) |
| precache manifest | webpack プラグインが注入 | createSerwistRoute が注入 |
| SW 登録 | register オプションで自動 | SerwistProvider コンポーネントで明示的に |
| Turbopack 対応 | 不可 | ネイティブ対応 |
移行手順
依存関係と設定
まずパッケージを入れ替え、next.config.ts を withSerwist でラップする。旧パッケージの withSerwistInit と違い、このラッパーは serverExternalPackages に esbuild を追加するだけで、SW 関連のオプションは一切受け取らない。潔い設計だ。
npm uninstall @serwist/next
npm install -D @serwist/turbopack esbuildimport { withSerwist } from "@serwist/turbopack";
export default withSerwist({
// ...既存の設定
});Route Handler の作成(移行の核心)
webpack プラグインとカスタム esbuild スクリプトの両方を、この1つの Route Handler が置き換える。
import { spawnSync } from "node:child_process";
import { createSerwistRoute } from "@serwist/turbopack";
const revision =
spawnSync("git", ["rev-parse", "HEAD"], {
encoding: "utf-8",
}).stdout.trim() || crypto.randomUUID();
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } =
createSerwistRoute({
swSrc: "src/app/sw.ts",
useNativeEsbuild: true,
additionalPrecacheEntries: [
{ url: "/en/~offline", revision },
{ url: "/ja/~offline", revision },
],
});createSerwistRoute が generateStaticParams や GET など Next.js Route Handler の標準エクスポートを返すのが良い。next build 時に通常の静的ルートとして SW がビルドされる。revision に git commit hash を使うことで、デプロイごとにプリキャッシュが自動的に更新される。CI 側で特別な処理は不要だ。
SerwistProvider による SW 登録
新しいアプローチのトレードオフとして、SW の登録が自動ではなくなった。SerwistProvider コンポーネントを明示的に使う必要がある。クライアントコンポーネントなので、Server Component のレイアウトから直接インポートできず、"use client" 付きの re-export ファイルを挟む。
"use client";
export { SerwistProvider } from "@serwist/turbopack/react";あとはレイアウトでラップし、swUrl に Route Handler のパス(/serwist/sw.js)を指定するだけだ。
残りの変更
sw.ts の import 元を @serwist/next/worker から @serwist/turbopack/worker に変更し、ミドルウェアのスキップ設定を /sw.js から /serwist/ に更新する。旧 scripts/build-sw.mjs と public/sw.js を削除すれば、ビルドスクリプトは velite build && next build --turbopack の2ステップに簡素化される。
結果
移行後のビルド出力:
○ (serwist) Using esbuild to bundle the service worker.
✓ (serwist) 47 precache entries (1210.78 KiB)
● /serwist/[path]
├ /serwist/sw.js.map
└ /serwist/sw.js
47エントリが自動検出・注入された。移行前はゼロだった。これだけで移行した価値がある。
まとめ
- フレームワークと戦わない設計を選ぶ。 Route Handler アプローチが機能するのは、ビルドシステムを迂回せず Next.js 自身のプリミティブを使っているからだ。ツール選定では、フレームワークの 外 ではなく 中 で動く設計を優先したい。
- 壊れた機能は静かに潜む。 プリキャッシュが数週間にわたって無機能だったことに気づかなかった。「動いているはず」のものは実際のビルド出力で検証しないと、たいてい動いていない。
- 明示的は暗黙的に勝る。
SerwistProviderを手動で追加するのは最初ダウングレードに感じたが、SW がどこでどう登録されるかが一目瞭然になり、デバッグが楽になった。
