SSR と Cloudflare バインディング
Cloudflare アダプターで動的ルートを配信し、Worker バインディング(シークレット・KV・D1 データベース)を SSR ハンドラ内から読み取る。
このページの内容
ルートをビルド時の静的レンダリングから除外し、@takazudo/zfb-adapter-cloudflare で Cloudflare Pages Worker としてデプロイし、ルートの SSR ハンドラ内から Cloudflare Worker バインディング(シークレット、環境変数、D1 データベース)を読み取る方法。
2 種類の Worker — まず区別する
zfb + Cloudflare のプロジェクトでは、どちらも「Worker」と呼ばれる 2 つの異なる概念があります。これらを混同することが、最もよくある混乱の原因です。
zfb が生成する dist/ — prerender = false をエクスポートするすべてのルートに対して Cloudflare アダプターが生成します。これは静的ページと同じ TSX パイプラインの中で動作します。共有レイアウト、コンポーネント、MDX 仮想モジュールはすべて、SSG ルートとまったく同じように機能します。
外部の単独 Worker — 別途デプロイされる、あなた自身の wrangler でビルドした Worker バンドル(認証 Worker、写真アップロード Worker、決済 Webhook Worker など)です。zfb はこれらをまったく認識しません。zfb と外部 Worker の継ぎ目は常に HTTP/JSON です。外部 Worker を fetch() する pages/api/*.tsx のプロキシルートか、直接 fetch() を呼ぶ prerender = false ページのどちらかになります。
これが重要な理由: AI エージェントや人間の読者は、共有レイアウトの TSX を外部 Worker に直接インポートしようとしがちです。それは機能しません。外部 Worker は別のバンドラ、別のランタイムを持ち、zfb の仮想モジュールレイヤーへのアクセスもありません。レイアウトを wrangler プロジェクトに import しようとしているなら、間違った境界を越えています。
生成された Worker が実際にどう動くかの概念的なメンタルモデルについては、SSR on a Worker (adapter mode) を参照してください。
zfb における SSG と SSR
デフォルトでは、zfb のすべてのページはビルド時に一度だけ静的 HTML にレンダリングされます(SSG)。これはコンテンツサイトにとって正しいデフォルトです。高速で、キャッシュ可能で、サーバーを必要としません。
リクエストごとに実行されなければならないルート(データベースの読み込み、セッションクッキーの確認、POST の処理など)は、単一のエクスポートで SSG から除外します:
// pages/api/products.tsx
export const prerender = false;prerender = false は、静的レンダリング中にこのページをスキップし、代わりに設定済みのアダプターに渡される SSR バンドルに含めるよう zfb build に指示します。
ルートが prerender = false をエクスポートしているのにアダプターが設定されていない場合、ビルドは問題のルート名を示すエラーとともに即座に失敗します。zfb はデプロイできないルートを黙って取りこぼすことはありません。
prerender = false の dev と本番の同等性
zfb dev は prerender = false のルートを、Cloudflare が本番で実行するのと同じレンダリングコードを通して動かします。開発サーバーは埋め込みの V8 アイソレート(ビルド時 SSG を駆動するのと同じもの)をホストし、開発ルーターは prerender = false の URL をリクエスト時にそのアイソレートへディスパッチします。ビルド時でも静的スナップショットからでもありません。
同等性の保証はバイト単位ではなく意味的です。ステータスコード、レスポンスボディ、Content-Type は dev とデプロイされた Cloudflare アダプターの間で一致します。実行ごとに正当に変わる値(レスポンスに刻まれるタイムスタンプ、ランダムに生成されるリクエスト ID など)は異なってもよいとされます。
これが実際に意味すること:
?id=…クエリパラメータに基づいて異なる HTML を返すページは、開発時のページ再読み込みのたびに正しい HTML をレンダリングします。前回のビルドの古いスナップショットではありません。例外を投げる SSR ハンドラは、
zfb build+ デプロイのあとに初めて失敗するのではなく、開発時にブラウザでインラインに V8 スタックトレースを表示します。プラグインの dev-middleware は依然として登録済みの URL を最初に要求します(プラグインルートは dev 専用のモックレスポンスなどのために SSR をオーバーライドできます)。SSR レイヤーはプラグインミドルウェアと静的ページキャッシュの間に位置します。
dev 側の SSR パスについて、実用上の注意が 1 つあります:
SSR のソース編集は自動で反映されますが、ブラウザは自動更新されません。 編集の tick ごとに zfb dev は再バンドルし、新しい V8 ホストを起動して実行中のサーバーに差し替え、古いホストをシャットダウンします。そのため prerender = false ルートへの次のリクエストは更新後のコードでレンダリングされます。ただし、SSR のみの編集では静的 HTML の書き込みが発生しないため、SSE の Page イベントが発火せず、開いているブラウザタブは自動的にはリロードされません。SSR ページを編集した後は、新しい出力を確認するためにブラウザタブを手動でリロードしてください。
prerender = false はリテラルなエクスポートでなければならない
zfb は prerender をビルド時の静的 AST 検査で検出します。ランタイムの評価ではありません。エクスポートはリテラルな export const 宣言でなければなりません:
export const prerender = false; // ✅ detected correctly次の形は検出されず、黙って SSG にフォールバックします:
/ / ❌ indirect assignment — not a literal export const const flags = { prerender: false }; export const prerender = flags. prerender; / / ❌ function call — not a literal export const export const prerender = computeFlag();同じ制約は frontmatter エクスポートにも適用されます。リテラルのみのコントラクトについては Frontmatter を参照してください。
Cloudflare アダプターの設定
アダプターをインストールし、zfb.config.json で名前を指定します:
pnpm add -D @takazudo/zfb-adapter-cloudflare{
"framework": "preact",
"adapter": "@takazudo/zfb-adapter-cloudflare"
}すると zfb build は dist/ の下に次を生成します:
すべての SSG ページの静的 HTML、そして
_worker.js+_zfb_inner.mjs—prerender = falseのルートを配信する Cloudflare Pages のアドバンストモード Worker エントリ。
dist/ を通常どおり Cloudflare Pages にデプロイします。Worker が動的ルートを処理し、静的アセットサーバーがそれ以外のすべてを処理します。
compatibility_flags = ['nodejs_compat'] は必須
アダプターはリクエスト単位の (env, ctx, request) コンテキストを AsyncLocalStorage(node:async_hooks 由来)を通して引き回し、getCloudflareContext() がそこから読み取ります。Workerd はデフォルトでは node:async_hooks を公開しません。wrangler.toml でオプトインする必要があります:
# wrangler.toml compatibility_flags = ["nodejs_compat"]このフラグがないと、Worker は node:async_hooks を欠落モジュールとして示すエラーとともに起動に失敗します。より深い仕組みについては SSR on a Worker (adapter mode) を参照してください。
SSR ハンドラから Worker の env を読む
Cloudflare Worker の fetch ハンドラは (request, env, ctx) を受け取ります。アダプターは env と ctx を、リクエスト単位の スコープを通してページに引き回すため、SSR ルートは getCloudflareContext() でそれらを読みます:
// pages/api/whoami.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
interface Env {
ANTHROPIC_API_KEY: string;
}
export default async function WhoAmI() {
const { env, ctx } = getCloudflareContext<Env>();
ctx.waitUntil(reportToAnalytics()); // fire-and-forget background work
return new Response(env.ANTHROPIC_API_KEY ? "ok" : "missing key");
}Env ジェネリックがバインディングの形を絞り込むため、TypeScript は env.ANTRHOPIC_KEY のようなタイプミスを捕捉します。
SSR リクエストの内部でのみ呼び出すこと
getCloudflareContext() は Worker のリクエストスコープの外(たとえばビルド時 SSG 中)で呼ばれると例外を投げます。これは設計どおりです。バインディングを必要とするルートは必ず prerender = false をエクスポートしなければなりません。ルートを両方のモードで動かしたい場合は、エラーをキャッチして分岐してください。
D1 データベース(env.DB)を読む
は Cloudflare のサーバーレス SQLite です。D1 バインディングは他のどのバインディングともまったく同じように env に公開されます。アダプターはこれを特別扱いしません。バインディングの TypeScript の形を宣言してクエリします:
// pages/api/products.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
interface Env {
// `D1Database` comes from `@cloudflare/workers-types`. Install it as
// a devDependency if you want the full typed surface; otherwise a
// minimal structural shape like the one below works too.
DB: D1Database;
}
export default async function Products() {
const { env } = getCloudflareContext<Env>();
// Always use `.bind(...)` for user input — D1 prepared statements
// are parameterised, which prevents SQL injection.
const { results } = await env.DB
.prepare("SELECT id, name, price_cents FROM products ORDER BY id")
.all();
return new Response(JSON.stringify({ products: results }), {
status: 200,
headers: { "content-type": "application/json" },
});
}単一行の読み込みには .first() を使います:
const product = await env.DB
.prepare("SELECT * FROM products WHERE id = ?")
.bind(productId)
.first();書き込み(INSERT / UPDATE / DELETE)には .run() を使います:
await env.DB
.prepare("INSERT INTO orders (user_id, total_cents) VALUES (?, ?)")
.bind(userId, totalCents)
.run();D1 バインディングの配線
D1 は wrangler.toml を通して Pages プロジェクトにバインドされます。バインディングの名前(下記の DB)が、env で読むプロパティになります:
# wrangler.toml
[[d1_databases]]
binding = "DB" # → env.DB inside the Worker
database_name = "webshop"
database_id = "<uuid>" # printed by `wrangler d1 create`エンドツーエンドのライフサイクル:
データベースを作成する —
wrangler d1 create webshop。これがdatabase_idを出力します。wrangler.tomlに貼り付けてください。マイグレーションを書く —
.sqlファイルをmigrations/(wrangler のデフォルト)の下に置きます。各マイグレーションは素の SQL(CREATE TABLEなど)です。マイグレーションを適用する —
wrangler d1 migrations apply webshop(ローカルの開発用データベースには--local、デプロイ済みのものには--remoteを追加)。デプロイする —
zfb buildを実行し、dist/を Cloudflare Pages にデプロイします。
プレビューと本番を分ける場合は、名前付き環境の下にバインディングを宣言し、それぞれが独自のデータベースを持つようにします:
[[d1_databases]]
binding = "DB"
database_name = "webshop"
database_id = "<production-uuid>"
[[env.preview.d1_databases]]
binding = "DB"
database_name = "webshop-preview"
database_id = "<preview-uuid>"ローカル開発
動作するエンドツーエンドの実例
zfb-example-webshopのデモは、まさにこのレシピを配線して動かしたものです。その dev:cf の package.json スクリプトと README の「ローカル開発」セクションは、以下で説明する 2 プロセスのループそのものです。スニペットを断片的にコピーするより全体が組み上がった 状態を見たい場合は、これをクローンしてください。(このデモはクライアント JS を一切 出力しませんが、それはショップ自身の設計上の選択であって zfb の制限ではありません。 ブラウザ JS が必要な場合、zfb は clientScript() 経由の .client.* クライアント スクリプトをサポートしています。)
アプリをローカルで動かす方法は 2 つあり、それぞれ異なる問いに答えます:
zfb dev— 高速なページ作成ループ。prerender = falseのルートの SSR レンダリングコードを埋め込みの V8 アイソレートを通して動かします(上記のprerender = falseの dev と本番の同等性 を参照)。ただし Worker バインディングは一切公開しません。zfb dev下の SSR ルートでgetCloudflareContext<Env>()を呼び出してもenvは得られないため、env.DB(または他のバインディング)を読むルートはここでは動作しません。 バインディングに触れないルートのレイアウト、スタイリング、ルーティングの反復作業に 使ってください。wrangler pages dev dist/— バインディングをリアルに再現するループ。ビルドされた_worker.jsをローカルの D1 データベース(.wrangler/下の SQLite ファイル)に 対して動かし、実際のcompatibility_flagsを反映し、本物のバインディングのバグを 表面化します。envを読む SSR ルートの作業をするときはこちらを使ってください。
このセクションの残りはバインディングをリアルに再現するループについて説明します。
初回セットアップ
ローカルの SQLite データベースに D1 マイグレーションを適用します(冪等 — 再実行しても 安全です):
wrangler d1 migrations apply webshop --localwebshop 引数は wrangler.toml の database_name と対応しています。存在しない場合は
. が作成されます。
編集→反映のループ
2 つのプロセスを並べて動かします: wrangler pages dev dist/(dist/ が変更されると
自動リロード)と、ソースファイルを編集するたびに zfb build を再実行するウォッチャー
です。最もきれいな方法は devDependencies の concurrently + chokidar-cli を使う
ことです:
pnpm add -D concurrently chokidar-cli次に dev:cf スクリプトを package.json に追加します(ウォッチのグロブはプロジェクトの
レイアウトに合わせて調整してください):
{
"scripts": {
"dev:cf:setup": "wrangler d1 migrations apply webshop --local && pnpm build",
"dev:cf": "pnpm dev:cf:setup && concurrently --names 'wrangler,watch' --kill-others 'wrangler pages dev dist/ --port 8788' \"chokidar 'pages/**/*.tsx' 'components/**/*.tsx' 'layouts/**/*.tsx' 'lib/**/*.ts' 'styles/**/*.css' --command 'pnpm build' --initial false --debounce 200\""
}
}次を実行します:
pnpm dev:cfTSX または CSS ソースファイルを編集すると、おおよそ 1〜2 秒でブラウザに変更が反映されます。
ウォッチャーが 200 ms デバウンスし、pnpm build が dist/ を再出力し、
wrangler pages dev が _zfb_inner.mjs の内容の変更に気づいて Worker を自動リロード
します。Ctrl-C で両プロセスがきれいに終了します。
`pnpm dev` と `pnpm dev:cf` を同時に実行しないこと
zfb dev の predev ステップ(rm -rf dist .zfb .zfb-build)は、wrangler pages devが現在配信中の dist/ ディレクトリを消去します。wrangler プロセスは、その後のリビルドが リロードをトリガーしなくなる劣化状態に入る可能性があります。一度に一つのループを選んで ください。誤って両方を実行してしまった場合は、すべてを停止し、pnpm build を再実行して から pnpm dev:cf を再起動してください。
トラブルシューティング
ポート 8788 で Address already in use。 別の wrangler pages dev がまだ動いて
います。lsof -ti TCP:8788 -sTCP:LISTEN | xargs kill で終了させるか、wrangler の
呼び出しに --port 8789 を渡してください。-sTCP:LISTEN フィルタが重要です。素の
lsof -ti TCP:8788 は接続中のブラウザ/クライアントの PID も返すため、これがないと
xargs kill が無関係なアプリを巻き添えにする可能性があります。
Worker が node:async_hooks を名指しするエラーで起動に失敗する。 wrangler.toml
に compatibility_flags = ["nodejs_compat"] がありません。アダプターは
node:async_hooks をトップレベルでインポートするため、このフラグがないと Worker は
そもそも起動しません。バインディングが欠けた状態でページを配信するのではなく、起動
自体に失敗します。このページの前にある "compatibility_flags = ['nodejs_compat'] は
必須" の警告を参照してください。このフラグはアダプターが env を SSR ルートに
引き渡すための必須条件であり、任意のオプトインではありません。
ページは表示されるが env.DB が undefined。 Worker は起動しています(つまり
nodejs_compat は設定済みです)が、D1 バインディングがそこに届いていません。
wrangler.toml のバインディング名(binding = "DB")が env で読むプロパティと
一致していること、そしてそのバインディングを宣言する wrangler.toml のあるディレクトリ
から wrangler pages dev dist/ を起動したことを確認してください。
編集後もブラウザに古いコンテンツが表示される。 通常、直前の pnpm build が失敗して
います。concurrently の出力内の [watch] ストリームでビルドエラーを確認してください。
wrangler のリロードは dist/ が実際に更新されたときにのみ発火します。
カート / D1 データが消えた。 ローカルの SQLite DB は . 下に
存在し、リビルドをまたいで永続します。.wrangler/ を削除した場合、別の作業
ディレクトリに切り替えた場合、または wrangler.toml の database_name を変更した場合に
リセットされます。それらの後に wrangler d1 migrations apply webshop --local を再実行
して新鮮なスキーマを取得してください。
なぜ 1 つではなく 2 つのプロセスなのか
zfb build は(小さなプロジェクトではサブ秒で)十分に高速なため、ユーザーランドの ウォッチャーを通じて保存のたびに実行することは、組み込みの --watch モードと区別が つきません。2 プロセスのレシピは、wrangler の Worker リロードの動作を、再実装不要な ブラックボックスとして保持します。