kuhaku.net

Next.js 16 を Cloudflare Workers に載せる — 本番でだけ出る 6 つのハマりどころ

  • Next.js
  • Cloudflare Workers
  • OpenNext

OpenNext を使うと、Next.js 16(App Router / RSC)を Cloudflare Workers に載せられます。ただし next devnext build では問題なく動くのに、本番の workerd ランタイムでだけ 404 や 500 になる罠がいくつかあります。自サイトを載せる過程で実際に踏んで解決したものを 6 つ記録します。最後に、これらを解決済みの最小スターターも紹介します。

前提: dev と本番は別物。preview で確認する

next dev は Node ランタイム、本番は workerd です。両者は挙動が違うため、Node では再現しないバグが本番で出ます。ローカルで本番相当を確認するには、workerd で動かす preview を使います。

opennextjs-cloudflare build && opennextjs-cloudflare preview

この記事の罠は、いずれも next dev では見えず、preview か本番で初めて現れるものです。Cloudflare 固有の挙動を触ったら、必ず preview で確認するのが近道です。

1. dynamicParams = false の SSG 動的ルートが本番で 404

動的ルート([slug] など)に dynamicParams = false を付けて静的に固定すると、next devnext build では 200 でも、workerd で 404 になることがあります。ビルドは通るので気づきにくい罠です。

対策は、dynamicParams を付けず、generateStaticParams で既知のパスを列挙し、未知の slug は notFound() で 404 にすること。これで dev・preview・本番が一致します。

export function generateStaticParams() {
  return getAllItems().map((i) => ({ slug: i.slug }));
}
// dynamicParams = false は付けない。未知の slug は notFound() で 404。

2. Workers に fs が無い(SSG でも実行時に再レンダされる)

OpenNext は SSG ページも実行時に再レンダリングすることがあります。そのため、ビルド時に生成済みのページでも、レンダリング中に fs.readFileSync を呼ぶと本番で 500 になります。Node の dev / build では成功するため、これも本番でだけ落ちます。

対策は、ファイルシステムに触れないこと。コンテンツはバンドルされる値(TS データや import する文字列・JSON)として持ち、大きいものは KV / R2 / static assets に置きます。この記事の本文自体も、fs ではなくバンドル文字列として配信しています。

3. opengraph-image は子セグメントに継承されない

ファイルベースの OG 画像(opengraph-image)は、ルートに置いても子のルートセグメントには適用されません。/items に置いても /items/[slug] では使われない、という挙動です。

対策は、OG 画像が必要なセグメントごとに opengraph-image を置くこと。動的ルートでは generateStaticParams で各 slug 分を静的生成します。

4. 環境変数は process.env でなく Cloudflare の env から読む

サーバー側で環境変数や bindings を読むときは、process.env ではなく getCloudflareContext() を使います。next dev でも、next.config の initOpenNextCloudflareForDev() により env が利用できます。

import { getCloudflareContext } from "@opennextjs/cloudflare";

export async function GET() {
  const { env } = getCloudflareContext();
  return Response.json({ greeting: env.GREETING });
}

型は wrangler types(cf-typegen)で生成される CloudflareEnv に揃えます。

5. scroll-behavior: smooth とハイドレーションでスクロールが効かない

CSS で scroll-behavior: smooth を効かせていると、JS の scrollToscrollIntoView の auto 指定が非同期のスムーズスクロールになり、ハイドレーション中の scroll reset に途中でキャンセルされることがあります。別ページからハッシュ付きで来たときに、目的のセクションへ飛ばない症状として出ます。

対策は、プログラム的に確実に着地させたい場面では instant を使うこと。

window.scrollTo({ top: y, behavior: "instant" });

6. ESLint の set-state-in-effect で useEffect 内 setState が弾かれる

eslint-config-next に含まれる react-hooks/set-state-in-effect により、useEffect 内の setState は lint エラーになります。マウント後にだけ確定するクライアント限定の値(hydration mismatch を避けたいもの)でよく引っかかります。

対策は、useEffect + useState ではなく useSyncExternalStore で表現すること。SSR では server snapshot を返します。

const mounted = useSyncExternalStore(subscribe, () => true, () => false);

まとめ

共通する教訓は一つで、dev で通っても本番(workerd)では別ということ。OpenNext で Cloudflare に載せるなら、preview で本番相当を確認する習慣が一番効きます。

ここで挙げた 6 つを解決済みの最小スターターを公開しています。

https://github.com/kuhku/opennext-cloudflare-starter

clone してそのまま動かせます。同じ道で詰まる人の時間を節約できれば幸いです。