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

> 管理画面やDBを持たず、Next.js SSGとCloudflare Workersでリクエスト時の処理を3本のAPIに絞った設計と運用境界を解説します。

Canonical URL: https://labs.eastbraver.com/blog/nextjs-cloudflare-workers-ssg-first
Published: 2026-07-17T18:51:00+09:00
Updated: 2026-07-31T12:32:00+09:00
Category: infrastructure
Tags: cloudflare, software-engineering

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

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

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

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

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

[CloudflareのNext.js対応表](https://developers.cloudflare.com/workers/framework-guides/web-apps/nextjs/)を見ると、OpenNext adapterでSSG・SSR・ISR・Route Handler・Server Actionsまで扱えます。ただ、使える機能と、このサイトに必要な機能は別です。

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

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

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

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

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

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

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

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

今後使いたい機能と同じプラットフォームに置きつつ、Next.jsの開発体験も使う。そのために[OpenNext](https://opennext.js.org/cloudflare/get-started)を介してCloudflare Workersへデプロイしています。

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

最初に分けたのは、機能の種類ではなく実行時点でした。

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

| 境界                                 | 現在数 | 実行時点          | このサイトでの役割                                 |
| ------------------------------------ | -----: | ----------------- | -------------------------------------------------- |
| `generateStaticParams`を持つ動的page |     17 | `next build`      | detail、taxonomy、paginationを既知のpathへ閉じる   |
| `force-static`の検索page             |      2 | page本体はbuild時 | queryの解釈とPagefind検索はbrowserで実行する       |
| 静的Route Handler                    |      4 | `next build`      | Sitemap Index、2本のRSS、health snapshotを生成する |
| request-time Route Handler           |      3 | request時         | contact、CSRF、CSP reportを処理する                |

Route Handlerは合計7本ですが、request時に動くのは3本です。`/sitemap.xml`、`/feed.xml`、`/digest/feed.xml`、`/api/health`は`dynamic = "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](https://nextjs.org/docs/app/api-reference/functions/generate-static-params)にも、`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です。

> [!NOTE] 静的化と公開状態は別の契約
> `published: false`のコンテンツはproduction loaderと`generateStaticParams`の両方から除外されます。`dynamicParams = false`が列挙外pathを404にするため、下書きのslugを直接指定してもrequest-time renderingへ逃げません。

未公開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へ集めて、どこで外部通信と検証が起きるのか分かるようにしておきたかったんです。

## 検索queryはbrowserへ逃がした

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

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

[Pagefind](https://pagefind.app/docs/running-pagefind/)は`next build`後の静的HTMLを走査し、browser用のindexとruntimeを生成します。検索requestはWorkerのDBにも外部の検索serviceにも行きません。配信済みindexの中で完結します。

### SSG-first配信経路

ブラウザへの通常配信はWorkers Static AssetsとOpenNext Workerが担います。OpenNext Workerはbuild済みSSG cache assetsまたは3本のrequest-time APIへ到達し、検索は配信済みPagefind indexを使ってbrowser内で完結します。

- ブラウザはWorkers Static AssetsまたはOpenNext Workerへ要求を送ります。
- OpenNext Workerはbuild済みSSG cache assetsか許可されたrequest-time APIを処理します。
- Pagefindは配信済み検索indexを使いbrowser内で検索を完結させます。

*静的配信を優先しrequest-time処理を例外に限定する経路*

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のビルド順序とキャッシュ実装](/blog/opennext-build-cache-pipeline)に分けて書きました。

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

`staticAssetsIncrementalCache`は、OpenNextがbuild済みSSG dataを読むためのread-only cacheです。zone cacheは公開responseのedge配信です。同じcacheという名前でも、生成物、key、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仕様](https://developers.cloudflare.com/workers/cache/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を明示的に削除する方針です。

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

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

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

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

[`workers_dev = false`](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/)を置く理由は、同じ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が返った。それだけで本番確認まで成功したことにはしません。判断できない結果は、safeではなくinconclusiveのまま残します。

## 今もこの方針でよかったと思っている

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

もちろん、守る場所がゼロになったわけではありません。request時に動くAPIは3本あり、zone cacheとOpenNextの配信境界もあります。Cloudflareや依存packageの更新もあります。

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

## SSG-firstを選ばない条件

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

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

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

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

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

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

## 再現用チェックリスト

- [ ] `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で最終確認する
