Next.js

Next.js を static export して Cloudflare Pages に置く:trailingSlash と _redirects の話

このサイトは Next.js を静的書き出しして、Cloudflare Pages に置いています。サーバーが要らないので運用がゼロになるのが理由です。ただし output: 'export' を付けた瞬間に使えなくなる機能があり、それを知らずに書いたコードが後から効いてきます。踏んだものを順に書きます。

// next.config.ts const nextConfig: NextConfig = { output: 'export', trailingSlash: true, images: { unoptimized: true }, };

使えなくなるもの

ビルド時に全ページを HTML として書き出すので、実行時に動くものは全部落ちます。

  • ミドルウェア(middleware.ts
  • ルートハンドラ(app/api/*/route.ts
  • next/image の最適化
  • ISR、オンデマンド再検証
  • 実行時の searchParams

ロケール判定してリダイレクトするミドルウェアを書いていたのですが、これは丸ごと諦めました。動的ルートも generateStaticParams がないとビルドが止まります。

逆に、勘違いしていて使えたのが fs です。サーバーコンポーネントの中でファイルを読む処理はビルド時にしか動かないので、静的書き出しでも問題ありません。記事の Markdown はこの方法で読んでいます。

const file = path.join(process.cwd(), 'data/articles', `${slug}-${lang}.md`); const content = await fs.readFile(file, 'utf-8');

trailingSlash を入れるなら、sitemap も揃える

trailingSlash: true にすると、/about へのアクセスは /about/ に 308 で飛ばされます。ここまでは想定通りでした。

揃えないといけないのが sitemap です。パスに末尾スラッシュを入れ忘れると、こうなります。

// 生成される URL が全部リダイレクト前のもの sitemapEntries.push({ url: `${baseUrl}${route}` });

サイトマップに載っている URL が全部リダイレクトになるので、クロールが一段無駄になります。Search Console でも「ページにリダイレクトがあります」で弾かれる分類です。パスの定義そのものに末尾スラッシュを含めておくのが確実でした。

export const ROUTES = { home: '/', about: '/about/', blog: '/blog/', } as const;

もう一つ、サイトマップには外部ドメインの URL を混ぜられません。別サブドメインで動かしているアプリも載せたくなりますが、受け付けるのは同一ホストの URL だけです。無視されるだけならまだしも、ファイル全体をエラー扱いされることがあります。

lastmodnew Date() を入れるのもやめました。デプロイのたびに全ページが「たった今更新された」と主張することになります。何度かやると lastmod 自体を信用してもらえなくなるので、静的なページには付けないほうがましです。

sitemap.tsrobots.ts には force-static が要る

これを忘れるとビルドが止まります。

export const dynamic = 'force-static';

エラーメッセージは親切なので詰まりはしませんが、ファイルを追加するたびに毎回忘れます。

リダイレクトは next.config に書けない

redirects() はサーバーが処理する機能なので、静的書き出しでは無視されます。Cloudflare Pages 側の仕組みを使います。public/_redirects に書いておくと、そのまま out/ にコピーされて Pages が読んでくれます。

/support.html /support/ 301

これが必要になったのは、App Store Connect に登録したサポート URL が /support.html だったからです。trailingSlash: true のサイトではこの URL は存在しません。App Store 側の URL は簡単には変えられないので、サイト側で受けるしかありませんでした。

opengraph-image.tsx は使えない

OG 画像を next/og で生成しようとして、これは諦めました。ファイル規約自体はビルドを通り、画像も正しく出ます。

$ file out/opengraph-image out/opengraph-image: PNG image data, 1200 x 630, 8-bit/color RGBA

中身は PNG なのに、ファイル名に拡張子がありません。 Cloudflare Pages は拡張子から Content-Type を決めるので、これを application/octet-stream として返します。OG 画像を取りに来るクローラーは画像として扱ってくれません。

結局、一度だけ生成して public/og.png として置き、メタデータから明示的に参照する形にしました。生成用のルートファイルはその後削除しています。ビルドのたびに作り直す必要のあるものでもないので、これで困っていません。

デプロイ

out/ をそのまま渡します。

npx wrangler pages deploy out --project-name your-project

Git 連携を使う手もありますが、手元でビルドして中身を確認してから上げたかったので CLI にしています。デプロイ前に canonical を全ページ確認する話はこちらに書きました。