Next.js 16 を Cloudflare Workers に載せる — 本番でだけ出る 6 つのハマりどころ
- Next.js
- Cloudflare Workers
- OpenNext
OpenNext を使うと、Next.js 16(App Router / RSC)を Cloudflare Workers に載せられます。ただし next dev や next 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 dev や next 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 の scrollTo や scrollIntoView の 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 してそのまま動かせます。同じ道で詰まる人の時間を節約できれば幸いです。