Next.jsのoutput: exportで詰まる5つの罠と回避策
Next.jsのアプリを output: export(静的書き出し)に切り替えてCloudflare PagesやGitHub Pages、純粋なCDNへ載せようとすると、next build までは通っていたのに途中のステップで急に詰まることがあります。
原因の多くは「コードのバグ」ではなく、静的書き出しに固有の前提を踏んでいないことです。静的書き出しでは配信時にNode.jsサーバーが存在しないため、リクエスト時に何かを生成する書き方はすべて使えなくなります。
この記事では、output: export でよく踏む5つの罠と、その回避策をまとめます。開発サーバー(next dev)側でハマるCSSの問題は別の原因なので、そちらは関連記事として最後にリンクします。
前提:output: exportとは何か
output: export は、Next.jsアプリをビルド時に完全な静的HTML・CSS・JSの集合として書き出すモードです。next.config.ts に次のように設定します。
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;
書き出した成果物(既定では out/ ディレクトリ)には、リクエスト時に動くサーバーコードは含まれません。サーバーランタイムを前提にした機能はすべて代替手段が必要になります。これが、これから紹介する罠の共通の根っこです。
罠1:メタデータ系ルートにforce-staticが必須
最初に踏みやすいのが、robots.ts や sitemap.ts、RSSフィードのルートハンドラです。これらは静的書き出しでは次の宣言が必須です。
// src/app/robots.ts
export const dynamic = "force-static";
export default function robots() {
return {
rules: [{ userAgent: "*", allow: "/" }],
sitemap: "https://example.com/sitemap.xml",
};
}
この宣言を付けないと、ビルドの「Collecting page data」段階で次のようなエラーが出て止まります。
Could not collect static generation routes.
Error: Cannot find module '<project>/.next/routes-manifest.json'
output: export ではすべてのルートをビルド時に静的生成する必要があり、force-static を付けないとフレームワークがそのルートを動的エンドポイントとして扱おうとして失敗します。
対象になりやすいルートは次の3つです。
src/app/robots.ts— robots.txt生成src/app/sitemap.ts— XMLサイトマップ生成src/app/feed.xml/route.ts(またはrss.xml)— RSSフィード生成
この要件はNext.js 15の移行ガイドに大きく書かれていません。新しくメタデータ系ルートを追加するたびに force-static を付ける、と覚えておくと事故が減ります。
罠2:RSS・sitemapは「ビルド時生成の静的ファイル」と考える
罠1を force-static で抜けたあとも、設計の考え方を変えておくと安定します。RSSやサイトマップを「リクエスト時にXMLを生成する動的ルート」として捉えるのはアンチパターンです。
静的書き出しやCDNは、配信物を積極的にキャッシュします。そのため、リクエスト時生成のつもりで書いたルートは次のどちらかになります。
- そもそも呼ばれない(CDNが最初のレスポンスをずっとキャッシュし続ける)
- 内容を更新するたびにビルドし直さないと反映されない
結論として、RSS・robots・sitemapはビルド時に生成され、out/ の中に静的ファイルとして出力されるものだと考えるのが正解です。force-static を付けたルートハンドラはまさにこの動きになります。これで「デプロイのたびに最新の内容で固定される」状態が作れます。
罠3:next/ogは静的書き出しで使えない
OGP画像を動的生成する next/og(旧 @vercel/og)は、静的書き出しでは使えません。next/og はSatori + ResvgをNode.jsサーバー上で実行して画像を描く仕組みのため、サーバーランタイムが無い静的書き出しとは相性が合いません。
回避策は、画像を事前生成して静的ファイルとして置くことです。
- OG画像をJPEGまたはPNGで用意し、
public/og/に置く - メタデータから静的URLで参照する
<meta property="og:image" content="https://example.com/og/article.png" />
動的なOG画像URLを1枚ずつサーバーで生成しようとすると、結局サーバーが必要になり、静的書き出しの意味が薄れます。記事ごとに画像を変えたい場合でも、ビルド時に事前生成しておくのが筋です。
罠4:trailingSlashの挙動とリンクへの影響
静的ホストやCDNでは、trailingSlash: true を設定しておくとパス解決が安定します。
// next.config.ts
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
// 出力URLは /articles/slug/ のように末尾スラッシュ付きになる
};
これを付けると、各ページが out/articles/slug/index.html のようにディレクトリ + index.html の形で書き出され、多くの静的ホストが期待通りに解決してくれます。
影響として、すべてのリンクが末尾スラッシュ付きのURLに揃うことを意識しておきます。next/link の <Link> を使っていれば自動で処理されますが、生の <a href> を手書きする場合は末尾の / を明示しておくと、リダイレクトやキャッシュのズレを避けられます。
罠5:動的srcのnext/imageは非対応
next/image の <Image> を動的な src(実行時に決まる画像URL)で使うのも静的書き出しでは詰まります。next/image はリクエスト時にリサイズやWebP変換といった最適化を行う仕組みですが、静的書き出しではすべての画像をビルド時に確定させる必要があるためです。
動的・ユーザー由来の画像は、標準の <img> タグに置き換えます。
// ✓ 正しい:動的srcは素のimgで
export default function Article({ article }: { article: { imageSrc: string } }) {
return <img src={article.imageSrc} alt="" />;
}
// ❌ 動的srcのnext/imageは静的書き出しで使えない
import Image from "next/image";
export default function Article({ article }: { article: { imageSrc: string } }) {
return <Image src={article.imageSrc} alt="" />;
}
一方、ビルド時に src が確定する静的importなら next/image を使えます。
import Image from "next/image";
import heroImage from "./hero.png"; // 静的import
export default function HomePage() {
return <Image src={heroImage} alt="" />;
}
どうしても next/image を動的に使いたい場合は next.config.ts で images.unoptimized: true を設定する方法もありますが、最適化の恩恵は失われます。静的書き出しでは「動的画像は素のimg、静的画像はnext/image」と切り分けるのが分かりやすいです。
デプロイ前チェックリスト
output: export でデプロイする前に、次を確認すると詰まりにくくなります。
next.config.tsにoutput: "export"を設定したかrobots.ts/sitemap.ts/ RSSルートにexport const dynamic = "force-static"を付けたか- RSS・sitemapをリクエスト時生成のつもりで書いていないか
next/ogによる動的OG画像を使っていないか(事前生成画像に置き換えたか)trailingSlash: trueを設定し、リンクが末尾スラッシュ前提になっているか- 動的
srcのnext/imageを素の<img>に置き換えたか - ビルド後に
out/の中身を実際に確認したか
まとめ
output: export で詰まるポイントの多くは、CSSやコードのバグではなく「配信時にサーバーが無い」という前提を踏んでいないことが原因です。
特にメタデータ系ルートの force-static 漏れは、Next.js 15の移行ガイドに大きく書かれていないため最初の関門になりがちです。新しいメタデータ系ルートを追加したら毎回 force-static を付ける、と機械的に決めておくのが安全です。
なお、next build は通るのに next dev だけCSSで落ちる、という別系統の問題もあります。これは静的書き出しではなく開発サーバー側の環境変数が原因なので、こちらの記事も参照してください。