@shinyaz

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 manifestwebpack プラグインが注入createSerwistRoute が注入
SW 登録register オプションで自動SerwistProvider コンポーネントで明示的に
Turbopack 対応不可ネイティブ対応

移行手順

依存関係と設定

まずパッケージを入れ替え、next.config.tswithSerwist でラップする。旧パッケージの withSerwistInit と違い、このラッパーは serverExternalPackagesesbuild を追加するだけで、SW 関連のオプションは一切受け取らない。潔い設計だ。

npm uninstall @serwist/next
npm install -D @serwist/turbopack esbuild
next.config.ts
import { withSerwist } from "@serwist/turbopack";
 
export default withSerwist({
  // ...既存の設定
});

Route Handler の作成(移行の核心)

webpack プラグインとカスタム esbuild スクリプトの両方を、この1つの Route Handler が置き換える。

src/app/serwist/[path]/route.ts
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 },
    ],
  });

createSerwistRoutegenerateStaticParamsGET など Next.js Route Handler の標準エクスポートを返すのが良い。next build 時に通常の静的ルートとして SW がビルドされる。revision に git commit hash を使うことで、デプロイごとにプリキャッシュが自動的に更新される。CI 側で特別な処理は不要だ。

SerwistProvider による SW 登録

新しいアプローチのトレードオフとして、SW の登録が自動ではなくなった。SerwistProvider コンポーネントを明示的に使う必要がある。クライアントコンポーネントなので、Server Component のレイアウトから直接インポートできず、"use client" 付きの re-export ファイルを挟む。

src/components/pwa/serwist-provider.tsx
"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.mjspublic/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 がどこでどう登録されるかが一目瞭然になり、デバッグが楽になった。

共有する

田原 慎也

田原 慎也

ソリューションアーキテクト @ AWS

AWS ソリューションアーキテクトとして金融業界のお客様を中心に技術支援を行っています。クラウドアーキテクチャや AI/ML に関する学びをこのブログで発信しています。

関連記事