1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

output:export に PWA を追加したら CI が壊れた — manifest.webmanifest の落とし穴

1
Posted at

はじめに

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.tsxmanifest: '/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.tsxmetadata.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.tsrobots.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
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?