メインコンテンツへスキップ

Next.jsとCloudflare WorkersをSSG-firstで設計した理由

管理画面やDBを持たない個人サイトで、Next.js SSGとCloudflare Workersを選んだ理由を解説します。認証もコンテンツDBも持たず、リクエスト時に実行する処理を3本のAPIへ絞り込み、管理対象と攻撃対象領域を減らした設計判断と運用境界を整理します。

(更新日:)16分で読めます
Next.jsとCloudflare WorkersをSSG-firstで設計した理由

このサイトを作るとき、最初に避けたかったのは管理画面とログイン機能でした。

記事を書くためだけに、認証・ユーザー管理・DBまで抱えたくなかったんです。管理画面そのものが脆弱という話ではありませんが、持ち込んだ時点で守る入口と日々の運用は確実に増えます。

MDXを置いたら、そのままデプロイできる。コンテンツ同士の関係も、最初からDBのスキーマで固定せず、MDXとメタデータを一次情報にする。

この方針からNext.jsのSSGを選び、デプロイ先はCloudflare Workersにしました。

このサイトでいうSSG-firstは、リクエスト時に実行する理由を説明できる処理だけWorkerへ残し、それ以外をビルド時に閉じる設計です。すべてを静的サイトへ変える、という意味ではありません。

CloudflareのNext.js対応表を見ると、OpenNext adapterでSSG・SSR・ISR・Route Handler・Server Actionsまで扱えます。ただ、使える機能と、このサイトに必要な機能は別です。

動的機能を足す前に、まずリクエスト時に動かす処理の上限を決めました。公開コンテンツの大半をビルド時に確定できるサイトだから選べた構成です。

管理画面とDBを持たないところから決めた

Next.jsを選んだ理由は、Markdownをビルド時にページへ変換し、必要なrouteを静的に閉じられるからです。

このサイトにCMSの管理画面はありません。記事はリポジトリ内のMDXが一次情報で、更新はMarkdownを直してビルド・デプロイするだけ。認証付きの編集画面やコンテンツDBを常時動かさなくてよいので、管理対象と攻撃対象領域を減らせます。

MDXとメタデータが残っていれば、検索・分類・関連記事の提案にも使えます。少なくとも個人サイトの初期設計で、管理画面とコンテンツDBを持つCMSから始める必要はない、と判断しました。

最初から持たないと決めたのは、次の入口です。

  • 記事を編集するための管理画面
  • 編集者を認証するログイン機能とユーザー管理
  • コンテンツ本文を保存する常時稼働のDB
  • 管理画面とDBを更新し続ける運用

Cloudflareを選んだのは単純に好きだったから

ここは正直、Workersだけを比較して決めたわけではありません。Cloudflareが単純に好きでした。

各種機能に魅力を感じていて、プラットフォーム全体をそちらへ寄せたい気持ちが先にありました。Next.jsを使うからといって、deploy先までVercel前提に固定したくなかった、という理由もあります。

今後使いたい機能と同じプラットフォームに置きつつ、Next.jsの開発体験も使う。そのためにOpenNextを介してCloudflare Workersへデプロイしています。

静的か動的かを実行時点で数える

最初に、機能を実行時点で分けました。

ビルド時に値を確定できるのか。それとも、リクエストが届くまで決められないのか。現行コードは次の構成です。

境界現在数実行時点このサイトでの役割
generateStaticParamsを持つ動的page17next builddetail、taxonomy、paginationを既知のpathへ閉じる
force-staticの検索page2page本体はbuild時queryの解釈とPagefind検索はbrowserで実行する
静的Route Handler4next buildSitemap Index、2本のRSS、health snapshotを生成する
request-time Route Handler3request時contact、CSRF、CSP reportを処理する

Route Handlerは合計7本ですが、request時に動くのは3本です。/sitemap.xml/feed.xml/digest/feed.xml/api/healthdynamic = "force-static"なので、ファイル形式や/api配下にあることだけを見て動的とは数えていません。

generateStaticParamsで17ルートをビルド時に閉じる

App Routerの動的segmentは、generateStaticParamsでpathを列挙できます。ただ、それだけでは列挙外のpathをrequest時に生成できる余地が残ります。

このサイトでは、該当する全pageでdynamicParams = falseを組み合わせています。

tsx
export const dynamicParams = false;

export async function generateStaticParams() {
  if (!isFeatureEnabledServer("blog")) return [];
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

これで、build時に返したslugだけが配信対象になり、未知のslugは404です。Next.js公式のgenerateStaticParamsにも、dynamicParams = falseでは列挙に含まれないpathを404にするとあります。

内訳は、Blog 4(detail・category・tag・pagination)、AI Digest 3(detail・tag・pagination)、News 3(detail・category・pagination)、Trails 2(landing・entry)、Guides・Legal・Products・Services・Works各1の17routeです。

この17routeを人の注意だけで守るのは無理があるので、ビルド時のvalidatorで見張っています。動的pageを走査し、generateStaticParamsがあるのにdynamicParams = falseがなければerrorです。

未公開sectionは既定で無効にし、retired URLにはlegacy redirectを置かず404を返します。存在しないpathを別pageへ救済するより、build時に作った公開面だけを配信したい、と考えました。

Workerに残したのは3本のAPI

request-timeのRoute Handlerとして残したのは、問い合わせの検証と中継、短時間だけ使うtokenの発行、security reportの受け取りです。どれも、リクエストが届くまで値を確定できません。

/api/healthは、build時刻と公開コンテンツのmanifest hashを返す静的snapshotへ変更しました。uptime probeとして同じデプロイ成果物を繰り返し確認できればよく、リクエストごとに計算する理由がなかったためです。

逆に、コンテンツ本文・OGP・RSS・検索indexはすべてビルド時に閉じました。Server Actionsも使えますが、このサイトでは不採用です。

動的な入口をRoute Handlerへ集めて、どこで外部通信と検証が起きるのか分かるようにしておきたかったんです。

Route Handlerリクエスト時でなければならない理由
/api/contact届いた入力を検証し、外部フォームへ中継する
/api/csrf短時間だけ有効なトークンを発行する
/api/csp-reportブラウザから届くCSP違反レポートを受け取る

検索queryはbrowserへ逃がした

検索ページは静的ですが、検索語はURL queryに入ります。

Server ComponentがsearchParamsを読む設計にすると、queryごとにserver側の判断が必要になります。そこで検索pageはforce-staticにし、browser側のClient Componentでqueryを読んでPagefindを呼ぶようにしました。

Pagefindnext build後の静的HTMLを走査し、browser用のindexとruntimeを生成します。検索requestはWorkerのDBにも外部の検索serviceにも行きません。配信済みindexの中で完結します。

Visual

SSG-firstで分けたビルド時とリクエスト時の境界

公開コンテンツ、検索index、OGP、RSSはビルド時に確定し、Static AssetsとOpenNextのSSG cache assetsから配信します。リクエスト時の処理は、問い合わせ、CSRF token、CSP reportの3本だけをOpenNext Workerの例外として残します。

ビルド時に閉じる通常経路とリクエスト時に残す3本の例外

SSG-firstで分けたビルド時とリクエスト時の境界

図の読み方

  1. MDXとmetadataをnext buildで処理し、公開pathと配信成果物をdeploy前に確定します。
  2. Pagefind、OGP、JavaScript、CSSはWorkers Static Assetsから配信し、検索はbrowser内で完結します。
  3. 既知のroute dataはOpenNext Workerがbuild済みSSG cache assetsから配信します。
  4. 問い合わせ、CSRF token、CSP reportだけは、リクエスト時に値が決まる3本のAPIとして残します。

SSG-firstといっても、output: exportとは別物です。現在はworker-cache.jsがactive entrypointとしてOpenNext Workerへ処理を渡し、OpenNextがbuild済みのroute dataを扱います。public由来のPagefindやOGPなどは、Workers Static Assets側です。

read-only cacheとzone cacheは別物

現在のdeploy経路には、wrangler.tomlのentrypointに指定したworker-cache.js、そこから委譲する.open-next/worker.js、そして.open-next/assetsを向いたasset bindingがあります。

SSG routeのincremental cacheは、OpenNextのread-onlyなStatic Assets実装staticAssetsIncrementalCacheです。ビルド後にscripts/copy-cache-to-assets.mjsを走らせ、cacheをasset binding配下へコピーします。

Pagefindの検索indexはnext buildの後、OpenNextが配信用assetをバンドルする前に生成します。順序を崩すと、.open-next/assetsへindexが入りません。read-only cacheとビルド順序はOpenNextとCloudflare Workersのビルド順序とキャッシュ実装に分けて書きました。

Cloudflareのzone Cache Ruleは別の配信層です。CDN-Cache-Controlを使う公開responseをedgeで扱いますが、/contact/digest/apino-storeのままです。zone cacheのpurgeとOpenNextのread-only cache assetsは同じ操作ではありません。

staticAssetsIncrementalCacheは、OpenNextがbuild済みSSG dataを読むためのread-only cacheです。zone cacheは公開responseのedge配信です。同じcacheという名前でも、生成物、key、purgeの責任が違います。

境界OpenNextのread-only cacheCloudflareのzone cache
保存するものビルド済みSSG data公開レスポンス
作る時点ビルド時リクエストへの応答時
主な利用者OpenNext WorkerCloudflare edge
更新方法新しいビルド成果物へ置換zone cacheをpurge

設定があってもrequestは通っていなかった

この設計で一番大きかった失敗は、cache設定の存在を、そのまま実行経路の存在だと思い込んだことでした。

当時は、active entrypointとは別に、実際のrequest経路から参照されていない古いwrapperが残っていました。cacheを分離するつもりで置いた設定も、この構成で有効なproject設定ではありませんでした。

設定もtestもある。でも、requestはその経路を通っていない。

証明できていたのは「書いた内容が残っていること」だけで、実装したつもりになっていました。

そこで、いったん実行経路を次の順で整理しました。

  1. deploy設定からactive entrypointを確認する
  2. 参照されないwrapperを削除する
  3. 対応していないcache設定を削除する
  4. OpenNextのstaticAssetsIncrementalCacheをactiveなcache契約にする
  5. build後にcacheがasset binding配下へコピーされたことを確認する

Workers Cacheの本番試験からdeploy後purgeへ切り替えた

その後、Wrangler 4.107系でWorkers Cacheを採用する段階になり、wrapperと[cache]設定を別の契約として実装し直しました。

2026年7月21日に本番試験を始め、HTML・RSC・prefetch・query別responseがMISSからHITへ移ること、Contact・API・404がBYPASSになることを確認しました。7日間のWorker invocationは全件successで、実利用者向けの5xxも観測していません。

ただし、2026年7月30日のversion境界試験で、cross_version_cache = falseを設定した新versionが旧versionのmarker付きresponseを返しました。同じNRT PoPで、no-storeのhealth responseは新版marker、cache対象responseは旧版markerでした。この時点では正しさを優先し、Workers Cacheをいったん無効化しています。

翌31日にCloudflareのWorkers Caching purge仕様を確認し、zone cacheのpurgeではWorkers Cacheが消えないことを整理しました。そこでworker-cache.js[cache]を戻し、deploy後にWorker自身からctx.cache.purge({purgeEverything:true})を実行します。purgeはzone、Workers Cache、zoneの順です。最初のzone purgeとWorkers Cache purgeの間に旧responseがzoneへ入り直す余地を、最後のzone purgeで閉じます。3回とも成功した後に、MISSからHITへの遷移を検証します。

cross_version_cache = falseは残しています。version境界だけへ正しさを預けず、deployごとにWorkers Cacheを明示的に削除する方針です。

日付確認したこと判断
2026-07-21MISSからHITへの遷移と、対象外レスポンスのBYPASSWorkers Cacheの本番試験を開始
2026-07-30新versionで旧marker付きレスポンスを確認正しさを優先して一時無効化
2026-07-31zone purgeではWorkers Cacheが消えない仕様を再確認zone → Workers Cache → zoneのpurgeをデプロイ経路へ追加

この失敗から得た判断は、かなり単純です。

entrypointから生成物とリクエストまで、実行経路を追えて初めて実装と呼べる。

本番のresponseは外から確かめる

SSG-firstを確認するときは、ソースコード・ビルド成果物・デプロイ後の外部responseを分けています。一つのgreen checkにまとめると、どこまで確認できたのか分からなくなるためです。ソースコードとビルド成果物の自動検証は続編に分けました。

workers_dev = falseを置く理由は、同じproduction HTMLがcustom domainとworkers.devの両方で公開されるduplicate hostを作らないためです。canonicalがcustom domainを指していても、別hostが200でindexable HTMLを返す状態は残したくありません。

deploy後はcustom domainとexpected hostへ外部からHTTP requestを送り、responseを別々に見ます。workers.dev側の404や403、custom domainへのredirectなら、duplicate hostはできていません。

一方、custom domain側でAccess loginやedgeのerror pageが返った場合、アプリケーションのsecurity headerやmetadataを確認できたことにはなりません。indexableな本番HTMLの200はrelease blocker、接続失敗や識別不能なresponseはinconclusiveです。

HTTP responseが返った。それだけで本番確認まで成功したことにはしません。判断できない結果は、inconclusiveのまま残します。

証拠ここまで確認できるここから先は確認できない
ソースコードrouteの静的化契約、entrypoint、cache設定実際に配信された成果物
ビルド成果物Pagefind・OGP・SSG cache assetsの同梱本番edgeが返したレスポンス
デプロイ後の外部レスポンスstatus、host、header、cache状態ソースやビルド工程の意図そのもの

今も採る条件と、選ばない条件

現時点では、この方針でよかったと思っています。Markdownを更新してdeployする流れは楽ですし、CMSの管理画面・ログイン機能・コンテンツDBを運用せずに済んでいます。

守る場所は残っています。request時に動くAPIは3本あり、zone cacheとOpenNextの配信境界もあります。Cloudflareや依存packageの更新もあります。

それでも、管理画面と認証基盤を持たないことで、そこから生まれる攻撃対象領域と運用対象は減らせました。個人サイトとして守る範囲を小さくできた実感があります。

公開pathと内容をビルド時に確定できるか。更新をdeploy単位で扱えるか。

どちらかを満たせないなら、私はSSG-firstを選択肢から外します。

request時にしか値が決まらない処理が主役なら、動的な処理を増やす方が自然です。ISRやon-demand revalidationが必要になった場合は、R2 incremental cache・Queue・Tag Cacheから要件に合う構成を選びます。細かい条件は続編で扱います。

SSG-firstで一番効いたのは、静的か動的かをpage単位だけで考えなかったことでした。route・query・検索index・RSS・OGP・cache・hostまで実行時点を決めると、Workerへ残す責任が見えてきます。

新しい動的機能を足すなら、なぜrequest時に実行するのかを説明できる状態にしておく。

これが、このサイトでSSG-firstを続ける条件です。

条件SSG-firstの判断
公開pathと内容をビルド時に確定でき、更新をデプロイまで待てる採る候補になる
ユーザーごとの内容や認証後の個別画面が主役選ばない
価格・在庫などをデプロイ単位では更新できない選ばない
ISRやon-demand revalidationが必要SSGを併用し、書き込み可能なcache構成を検討する

再現用チェックリスト

  • generateStaticParamsを持つ全pageへdynamicParams = falseを置き、列挙外pathを404にする
  • feature flagとpublished: falseがstatic paramsから除外されることをtestする
  • Route Handlerを静的生成とrequest-time実行に分ける
  • server側でsearchParamsを読まずquery処理をClient Componentへ閉じる
  • Pagefindをnext build後かつOpenNext build前に生成する
  • .open-next/cacheがasset binding配下へ届くことを確認する
  • Wrangler dry-runでWorker gzipとasset総量を別々に測る
  • active entrypointがworker-cache.jsを指し、OpenNext Workerへ委譲することを確認する
  • deploy後にzone cacheとWorkers Cacheを両方purgeする
  • Workers Cacheではversion markerを使い、新旧versionのresponseが混ざらないことを本番で確認する
  • workers.devのexpected hostをdeploy後にprobeする
  • Node.jsではなくWorkers runtimeのpreviewで最終確認する

Written by

柿添貴士(TakashiKakizoe) profile photo

柿添貴士(TakashiKakizoe)

Webエンジニア/テックリード

Fukuoka, Japan

  • PHP
  • Laravel
  • Next.js
  • AWS
  • Cloudflare
  • AI Agent