はじめに
PWA 対応の小さな PR を出した。やったことは、ほぼ 1 行。アプリの metadata に manifest を足しただけ。ホーム画面に追加できるようにするための、ありふれた対応だ。
ところが、CI(コードを push するたびに自動で回るテスト群)のチェックが全部赤くなった。
しかも落ちたのは E2E テスト(画面操作を端から端まで自動で検証するテスト)。エラーメッセージにはこう書いてある。
Error: Process from config.webServer was not able to start. Exit code: 1
「webServer が起動できない」。manifest の話はどこにも出てこない。テストコードは一行も触っていないのに、なぜ E2E が落ちるのか。原因にたどり着くまで、まるで見当違いの場所を掘り続けることになった。
この記事を貫くテーマは、次のとおりだ。
-
動的ルートと静的エクスポートは、設計思想が真逆:Next.js が自動生成する manifest は「サーバーで動く」前提。
output: 'export'は「サーバーが無い」前提。だから噛み合わない。 - エラーは、原因と別の場所に顔を出す:本当の原因は manifest のビルド失敗なのに、CI には「E2E が起動できない」という無関係に見える顔で現れる。ログを最初から読まないと気づけない。
結論だけ先に言うと、直し方は 1 行だ。
export const dynamic = 'force-static';
なぜこの 1 行で直るのか。そこに至るまでに何を疑い、何を読み違えたのか。順を追って解きほぐしていく。
1. 何が起きたか — 1 行の PR が CI を全部落とした
まず、やったことを正確に書く。Next.js(App Router)のアプリで、PWA 対応のために layout.tsx の metadata へ manifest フィールドを足した。
// app/layout.tsx
export const metadata = {
manifest: '/manifest.webmanifest',
};
これで HTML の <head> に <link rel="manifest"> が入り、ブラウザが「このサイトはインストールできる」と認識する。狙いはそれだけ。テストもインフラ設定も触っていない。
PR を上げると、CI が次々と赤くなった。とくに目立ったのが 2 つのエラーだ。
Error: export const dynamic = "force-static"/export const revalidate not configured
on route "/manifest.webmanifest" with "output: export".
Error: Process from config.webServer was not able to start. Exit code: 1
1 つ目は manifest の名前が出ているので、まだ手がかりになる。問題は 2 つ目だ。E2E テスト(Playwright)が dev server を立ち上げようとして失敗している。表のチェック一覧では、この E2E の失敗が一番大きく赤く見える。
ここで思考がねじれる。「触ったのは metadata だけ。なのに落ちているのは E2E。テストが壊れたのか?」と、原因を間違った方向に探し始めてしまう。
実際の連鎖はこうだ。
火元は一番上の manifest。だが煙が一番濃く出るのは一番下の E2E。火事の通報を煙の場所からたどると、火元を見失う。この記事の本題は、まさにこの「火元と煙の距離」にある。
2. 前提:Web Manifest と PWA とは
原因の話に入る前に、登場人物を紹介しておく。知っている人は読み飛ばしてかまわない。
Web Manifest とは、ブラウザに「このサイトはアプリとしてインストールできる」と伝えるための JSON ファイルだ。中身はこんな具合になる。
{
"name": "MyApp",
"short_name": "MyApp",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }],
"start_url": "/",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff"
}
アプリ名、アイコン、起動 URL、表示モード、テーマカラー。スマホで「ホーム画面に追加」したときのアイコンや、起動時のスプラッシュ画面は、ここの情報から作られる。これが PWA(Progressive Web App)、つまり「Web サイトをアプリのように扱う仕組み」の土台になる。
ブラウザにこのファイルを読ませるには、HTML の <head> に 1 行入れる。
<link rel="manifest" href="/manifest.webmanifest" />
Next.js(App Router)では、この <link> を自分で書く必要はない。metadata に manifest フィールドを足せば、フレームワークが自動で挿入してくれる。
// app/layout.tsx
export const metadata = {
manifest: '/manifest.webmanifest',
};
たった 1 行で <link> タグが入る。とても便利だ。だが、この「便利」の裏で Next.js が何をやっているかを知らないと、今回の落とし穴を踏む。manifest という名刺の裏に、別の顔が隠れている。
3. 前提:output:'export'(静的エクスポート)とは
もう 1 人の登場人物が output: 'export' だ。これも押さえておく。
Next.js は通常、ページを リクエストのたびにサーバーで生成 する。ユーザーがアクセスするたびに Node.js が動き、その場で HTML を組み立てて返す。
ところが next.config.mjs に 1 行足すと、動きが根本から変わる。
// next.config.mjs
const nextConfig = {
output: 'export', // ← これ
};
output: 'export' を付けると、Next.js は ビルド時(next build)に全ページの HTML を作り切る。あとはサーバー不要で、出来上がったファイルをそのまま配信するだけになる。
この 2 つのモードの違いを並べると、こうなる。
通常モードは「注文を受けてから料理する」レストラン。output: 'export' は「作り置きを並べておく」総菜屋だ。総菜屋には厨房(サーバー)がいらない。完成品が棚に並んでいるだけだから、安いし、客が増えても並べる数を増やせばいい。
だからこそ AWS Amplify Hosting や Cloudflare Pages、S3 + CloudFront といった「サーバーレスな静的ホスティング」に乗せやすい。コストが低く、スケールに強い。個人開発やスタートアップで output: 'export' を選ぶ理由は、たいていここにある。
ここで大事な前提を一つ確認しておく。output: 'export' の世界には、リクエストを受けて動くサーバーが存在しない。 この一点が、次章の衝突の核心になる。
4. 衝突の正体 — 動的ルートと静的エクスポートは思想が逆
では、なぜ manifest を足すとビルドが止まるのか。
layout.tsx に manifest: '/manifest.webmanifest' を書く。すると Next.js は内部で、その URL を処理する ルートハンドラー を自動生成する。<link> タグを挿入するだけでなく、その URL にアクセスが来たら JSON を返す「窓口」まで一緒に用意してしまうのだ。
そしてこのルートハンドラーは、Next.js の設計上 「サーバーが動いていて、リクエストのたびにレスポンスを返す」 ことを前提にしている。これが「動的ルート」と呼ばれる所以だ。
ところが、前章で確認したとおり output: 'export' の世界にサーバーはいない。
ここで前提が真っ向からぶつかる。
output: 'export' |
manifest のルートハンドラー | |
|---|---|---|
| サーバー | 不要(静的ファイルのみ) | 必要(サーバーで動く前提) |
| 生成タイミング | ビルド時に 1 回 | リクエストのたび |
「サーバーは無い」と言い張る設定と、「サーバーがある前提」で作られたルートが、同じプロジェクトに同居している。総菜屋なのに、厨房で注文を受けて作る前提のメニューが紛れ込んでいるようなものだ。
Next.js はビルド時にこの矛盾を検出して、止まる。
Error: export const dynamic = "force-static"/export const revalidate
not configured on route "/manifest.webmanifest" with "output: export".
このエラー文を意訳すると、こうなる。
「
output: exportモードなのに、/manifest.webmanifestが『静的に生成していい』という設定になっていない。このままだとビルドできない」
裏を返せば、Next.js は「これは静的に作っていいルートだ」と宣言してくれさえすれば、文句を言わない。次の章で、その宣言にあたる解決策を見ていく。
5. なぜ manifest は静的でいいのか
解決策の前に、一つ腑に落としておきたい問いがある。「そもそも manifest を動的ルートにする必要があるのか?」だ。
manifest の中身を思い出してほしい。
- アプリ名
- アイコンのパス
- テーマカラー
- 表示モード(standalone など)
これらは ユーザーごとに変わるか? リクエストのたびに変わるか? どちらも No だ。誰がアクセスしても、いつアクセスしても、返ってくる JSON は同じ。ログイン状態でアプリ名が変わったりはしない。
つまり manifest は、本質的に ビルド時に 1 回作れば十分 な情報の塊だ。動的ルートとして「リクエストのたびに生成する」のは、固定された名札を客が来るたびに印刷し直すようなもので、ただの無駄になる。
ということは、正しい姿勢はこうだ。
「この manifest は、ビルド時に 1 回だけ作って、あとは静的ファイルとして配ってくれ」
これを Next.js に伝えられれば、output: 'export' との矛盾は消える。サーバーがいらない情報なのだから、サーバー前提をやめればいい。次の 1 行が、まさにその意思表示になる。
6. 解決策 — force-static の 1 行
直し方はシンプルだ。manifest を返すファイル(src/app/manifest.ts など)の先頭に、次の 1 行を足す。
// src/app/manifest.ts
export const dynamic = 'force-static'; // ← これを追加
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'MyApp',
short_name: 'MyApp',
icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }],
start_url: '/',
display: 'standalone',
theme_color: '#ffffff',
background_color: '#ffffff',
};
}
export const dynamic = 'force-static' は、Next.js への宣言だ。
「このルートは、ビルド時に 1 回だけ生成する静的ファイルとして扱っていい」
前章で確認した「manifest は事前生成で十分」という事実を、コードで明言したことになる。これで Next.js は manifest ルートを静的ファイルとして書き出し、output: 'export' と問題なく共存する。ビルドは通り、CI も緑に戻る。
ここで「自分は manifest.ts なんてファイル作ってないぞ」と思った人もいるはずだ。第 2 章のように layout.tsx の metadata.manifest だけで済ませている場合、force-static を書く場所が無い。その場合は、metadata 方式をやめて manifest.ts を明示的に作り、そこに force-static を書くのが確実だ。public/manifest.webmanifest に静的ファイルとして直接置き、<link> だけ自前で張る手もある。
要するに直し方は 2 択になる。
| アプローチ | やること | 向いている場合 |
|---|---|---|
manifest.ts + force-static
|
ルートファイルを作り 1 行足す | 型補完を効かせて TS で管理したい |
public/ に静的配置 |
JSON を直接置き <link> を自前で張る |
フレームワークに任せず手で持ちたい |
どちらでも結果は同じ。「サーバー前提の動的ルートを、静的ファイルに変える」ことが本質だ。
7. 一番のハマりどころ — エラーは原因と別の場所に出た
ここからが、この記事で一番伝えたい部分だ。直し方そのものより、原因にたどり着くまでが長かった 理由を書く。
CI のログをもう一度見てほしい。一番大きく赤く出ていたのはこれだった。
Error: Process from config.webServer was not able to start. Exit code: 1
このメッセージのどこにも、manifest の文字は無い。書いてあるのは「E2E テストの webServer(dev server)が起動できなかった」だけ。だから最初は、Playwright の設定や E2E のコードを疑った。ポートが衝突したのか、待機時間が足りないのか、と。完全に見当違いの場所を掘っていた。
実際の伝播はこうだ。
火元(manifest)と、表に出る煙(webServer 起動失敗)が、別の場所にある。E2E は dev server が立っていることを前提にテストを始める。だがその手前で next build がコケているので、そもそもサーバーが立たない。立たないサーバーに対して「起動できなかった」とだけ報告される。火事の通報内容が「煙が出ている」だけで、火元の住所が書いていない状態だ。
教訓は 2 つある。
1 つ目。CI のログは、一番下(最後の失敗)ではなく最初から読む。 最後に出るのは派生的な失敗で、火元はもっと上にある。今回なら、E2E のログより前に出ていた not configured on route "/manifest.webmanifest" が真犯人だった。スクロールを面倒がった分だけ、遠回りした。
2 つ目。「触っていない場所が落ちた」ときほど、ビルド全体の前提を疑う。 テストコードを触っていないのにテストが落ちるなら、テストより前の工程(ビルド)が壊れている可能性が高い。症状の出ている場所と、原因のある場所は、しばしば離れている。
このタイプのバグは、デバッグの目を「症状」から「工程の上流」へ引き戻せるかで、かかる時間が桁で変わる。
8. 関連 — sitemap / robots / OGP 画像でも同じ罠
この落とし穴は manifest 専用ではない。output: 'export' を使っている限り、Next.js が自動生成する 動的ルートハンドラーはすべて同じエラーになる。App Router でよく使う「特殊ファイル」が軒並み該当する。
| ファイル | 役割 | 同じ罠を踏むか |
|---|---|---|
src/app/manifest.ts |
Web Manifest | ○ |
src/app/sitemap.ts |
サイトマップ(sitemap.xml) |
○ |
src/app/robots.ts |
クローラ制御(robots.txt) |
○ |
src/app/opengraph-image.tsx |
OGP(SNS シェア時のサムネ)画像の動的生成 | ○ |
どれも「中身はビルド時に決まる=静的で問題ない」ものばかりだ。だから対処も manifest とまったく同じで、各ファイルの先頭に 1 行足せばいい。
export const dynamic = 'force-static';
とくに sitemap.ts と robots.ts は SEO 対応でほぼ必ず入れる。manifest だけ直して安心していると、次に sitemap を足したときに同じエラーで同じ時間を溶かす。output: 'export' のプロジェクトを触っているなら、これらを最初にまとめて棚卸ししておくと、未来の自分を一度に救える。一度踏んだ地雷の場所を地図に書いておくのと同じだ。
なお opengraph-image.tsx で本当に「リクエストごとに違う画像」を返したい場合は、そもそも output: 'export' と相性が悪い。静的エクスポートを続けるなら画像は事前生成に倒す、という設計判断になる。
おわりに
起きたことを 1 枚にまとめておく。
- Next.js の
output: 'export'(静的エクスポート)は、サーバーを持たないモード。 - PWA の manifest などは、Next.js が内部で「サーバー前提の動的ルートハンドラー」として生成する。
- 動的ルートと静的エクスポートは設計思想が逆で、共存できず、ビルドが止まる。
- 解決は
export const dynamic = 'force-static'を 1 行足すだけ。 - CI ではこの失敗が「webServer が起動できない」という別の顔で出るので、火元が見えにくい。
この記事を貫いた二本柱を、もう一度。
- 動的ルートと静的エクスポートは設計思想が逆:便利な自動生成ほど、裏で「サーバーがいる前提」を持ち込む。サーバーの無い世界では、それを静的に倒す宣言が要る。
- エラーは原因と別の場所に顔を出す:症状の場所を疑う前に、工程の上流とログの先頭に戻る。
最後に、今日からできる一歩を一つ。自分のプロジェクトで output: 'export' を使っているなら、app/ 配下に manifest.ts / sitemap.ts / robots.ts / opengraph-image.tsx が無いか、あるいはこれから足す予定が無いかを棚卸ししてほしい。あるなら、踏む前に force-static を入れておく。1 行の予防が、ある日の CI 全落ちと半日のデバッグを、丸ごと無かったことにしてくれる。
参考
- Next.js 公式ドキュメント:Deploying — Static Exports(
output: 'export'の制約一覧) - Next.js 公式ドキュメント:Metadata Files —
manifest.json - Next.js 公式ドキュメント:Route Segment Config —
dynamicオプション - MDN Web Docs:Web app manifests