SSR and Cloudflare Bindings
Serve dynamic routes with the Cloudflare adapter and read Worker bindings — secrets, KV, and D1 databases — from inside an SSR handler.
What this page covers
How to opt a route out of build-time static rendering, deploy it as a Cloudflare Pages Worker with @takazudo/zfb-adapter-cloudflare, and read Cloudflare Worker bindings — secrets, environment variables, and aD1 database — from inside the route's SSR handler.
Two kinds of Worker — disambiguate first
There are two distinct concepts both called "Worker" in a zfb + Cloudflare project. Blurring them is the most common source of confusion.
zfb's emitted dist/ — produced by the Cloudflare adapter for every route that exports prerender = false. It runs inside the same TSX pipeline as static pages: shared layouts, components, and MDX virtual modules all work exactly as they do for SSG routes.
External standalone Workers — your own wrangler-built Worker bundles deployed separately (e.g. an auth Worker, a photo-upload Worker, a payment-webhook Worker). zfb has no awareness of them. The seam between zfb and an external Worker is always HTTP/JSON: either a pages/api/*.tsx proxy route that fetch()'s the external Worker, or a prerender = false page that calls fetch() directly.
Why this matters: AI agents and human readers often try to import shared layout TSX directly into an external Worker. That doesn't work — an external Worker has a different bundler, a different runtime, and no access to zfb's virtual-module layer. If you find yourself trying to import a layout into a wrangler project, you are crossing the wrong boundary.
For the conceptual mental model of how the emitted worker actually runs, see SSR on a Worker (adapter mode).
SSG vs SSR in zfb
By default every page in zfb is rendered once at build time into static HTML (SSG). That is the right default for content sites — it is fast, cacheable, and needs no server.
A route that must run per request — reading a database, checking a
session cookie, handling a POST — opts out of SSG with a single
export:
// pages/api/products.tsx
export const prerender = false;prerender = false tells zfb build to skip this page during static
rendering and instead include it in the SSR bundle that is handed
to your configured adapter.
If a route exports prerender = false but no adapter is configured,
the build fails fast with an error naming the offending route — zfb
will not silently drop a route it cannot deploy.
dev-prod parity for prerender = false
zfb dev runs prerender = false routes through the same render
code Cloudflare runs in production. The dev server hosts an embedded
V8 isolate (the same one that drives build-time SSG), and the dev
router dispatches a prerender = false URL into that isolate at
request time — not at build time, not from a static snapshot.
The parity guarantee is semantic, not byte-for-byte: status code,
response body, and Content-Type match between dev and the deployed
Cloudflare adapter. Values that legitimately vary across runs
(timestamps stamped into a response, randomly generated request IDs)
are allowed to differ.
What this means in practice:
A page that returns different HTML based on
?id=…query parameters renders the right HTML on every dev page reload — no stale snapshot from the last build.An SSR handler that throws shows you the V8 stack trace inline in the browser at dev time, instead of failing only after
zfb builddeploy.
Plugin dev-middleware still claims its registered URL first (plugin routes can override SSR for things like dev-only mock responses); the SSR layer sits between plugin middleware and the static page cache.
One practical note about the dev-side SSR path:
SSR source edits are picked up automatically, but the browser does not
auto-refresh. On every edit tick, zfb dev re-bundles, starts a fresh V8
host, swaps it into the running server, and shuts the old host down — so the
next request to a prerender = false route renders the updated code. However,
SSR-only edits produce no static HTML writes, which means no SSE Page event
fires and the open browser tab does not automatically reload. Reload the
browser tab manually after editing an SSR page to see the new output.
prerender = false must be a literal export
zfb detects prerender via static AST inspection at build time, not runtime evaluation. The export must be a literal export const declaration:
export const prerender = false; // ✅ detected correctlyThese forms are not detected and silently fall back to 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();The same restriction applies to the frontmatter export — see Frontmatter for the literal-only contract.
Configuring the Cloudflare adapter
Install the adapter and name it in zfb.config.json:
pnpm add -D @takazudo/zfb-adapter-cloudflare{
"framework": "preact",
"adapter": "@takazudo/zfb-adapter-cloudflare"
}zfb build then produces, under dist/:
the static HTML for every SSG page, and
_worker.js+_zfb_inner.mjs— a Cloudflare Pages advanced-mode Worker entry that serves yourprerender = falseroutes.
Deploy dist/ to Cloudflare Pages as usual. The Worker handles dynamic
routes; the static asset server handles everything else.
compatibility_flags = ['nodejs_compat'] is mandatory
The adapter threads the per-request (env, ctx, request) context through anAsyncLocalStorage (from node:async_hooks) that getCloudflareContext() reads from. Workerd does not expose node:async_hooks by default — you must opt in via wrangler.toml:
# wrangler.toml compatibility_flags = ["nodejs_compat"]Without this flag the Worker fails to boot with an error naming node:async_hooksas the missing module. SeeSSR on a Worker (adapter mode)for the deeper mechanism.
Reading the Worker env from an SSR handler
A Cloudflare Worker's fetch handler receives (request, env, ctx).
The adapter threads env and ctx to your page through a per-request
scope, so an SSR route reads them with
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");
}The Env generic narrows the bindings shape so TypeScript catches a
typo like env.ANTRHOPIC_KEY.
Call it only inside an SSR request
getCloudflareContext() throws if called outside a Worker request scope — for example during build-time SSG. That is by design: a route that needs bindings must export prerender = false. If you want a route to work in both modes, catch the error and branch on it.
Reading a D1 database (env.DB)
is Cloudflare's serverless SQLite. A D1 binding is exposed on
env exactly like any other binding — the adapter does not treat it
specially. Declare the binding's TypeScript shape and query it:
// 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" },
});
}Single-row reads use .first():
const product = await env.DB
.prepare("SELECT * FROM products WHERE id = ?")
.bind(productId)
.first();Writes (INSERT/UPDATE/DELETE) use .run():
await env.DB
.prepare("INSERT INTO orders (user_id, total_cents) VALUES (?, ?)")
.bind(userId, totalCents)
.run();Wiring up the D1 binding
D1 is bound to your Pages project through wrangler.toml. The binding
name (DB below) is the property you read on env:
# wrangler.toml
[[d1_databases]]
binding = "DB" # → env.DB inside the Worker
database_name = "webshop"
database_id = "<uuid>" # printed by `wrangler d1 create`The lifecycle, end to end:
Create the database —
wrangler d1 create webshop. This prints thedatabase_id; paste it intowrangler.toml.Write migrations — put
.sqlfiles undermigrations/(the wrangler default). Each migration is plain SQL —CREATE TABLE, etc.Apply migrations —
wrangler d1 migrations apply webshop(add--localfor the local dev database,--remotefor the deployed one).Deploy —
zfb buildthen deploydist/to Cloudflare Pages.
For a preview vs production split, declare the binding under a named environment so each gets its own database:
[[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>"Local development
A working end-to-end example
The zfb-example-webshopdemo is this exact recipe, wired up and running: its dev:cf package.json script and its README "Local development" section are the same two-process loop described below. Clone it if you want to see the whole thing assembled rather than copy the snippets piecemeal. (That demo ships no client JS — that is the shop's own design choice, not a zfb limitation; zfb supports.client.* client scripts via clientScript() when you do want browser JS.)
There are two ways to run the app locally, and they answer different questions:
zfb dev— fast page-authoring loop. It runs the SSR render code forprerender = falseroutes through the embedded V8 isolate (see dev-prod parity forprerender = falseabove), but it exposes no Worker bindings: callinggetCloudflareContext<Env>()from an SSR route underzfb devyields noenv, so any route that readsenv.DB(or any other binding) cannot work here. Use this for layout, styling, and routing iteration on routes that do not touch bindings.wrangler pages dev dist/— binding-realistic loop. Runs the built_worker.jsagainst a local D1 database (a SQLite file under.wrangler/), serves your realcompatibility_flags, and surfaces real binding bugs. Use this whenever you're working on an SSR route that readsenv.
The rest of this section covers the binding-realistic loop.
One-time setup
Apply your D1 migrations to the local SQLite database (idempotent — safe to re-run):
wrangler d1 migrations apply webshop --localThe webshop argument matches database_name in your wrangler.toml. This
creates . if it doesn't exist.
The edit-refresh loop
Run two processes side-by-side: wrangler pages dev dist/ (which auto-reloads
when dist/ changes) and a watcher that re-runs zfb build whenever you edit a
source file. The cleanest way is concurrently + chokidar-cli from your
devDependencies:
pnpm add -D concurrently chokidar-cliThen add a dev:cf script to your package.json (adjust the watch globs to
match your project layout):
{
"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\""
}
}Then:
pnpm dev:cfEdit a TSX or CSS source file and the browser reflects the change in roughly
1–2 seconds: the watcher debounces for 200 ms, pnpm build re-emits dist/,
and wrangler pages dev notices the _zfb_inner.mjs content change and reloads
the worker automatically. Ctrl-C kills both processes cleanly.
Do NOT run `pnpm dev` and `pnpm dev:cf` at the same time
zfb dev's predev step (rm -rf dist .zfb .zfb-build) wipes the dist/directory that wrangler pages dev is actively serving. The wrangler process can enter a degraded state where subsequent rebuilds no longer trigger reloads. Pick one loop at a time. If you accidentally ran both, stop everything, re-runpnpm build, and restart pnpm dev:cf.
Troubleshooting
Address already in use on port 8788. Another wrangler pages dev is still
running. Either kill it (lsof -ti TCP:8788 -sTCP:LISTEN | xargs kill) or pass
--port 8789 to the wrangler invocation. The -sTCP:LISTEN filter is important:
a bare lsof -ti TCP:8788 also returns any connected browser/client PIDs, so
without it xargs kill can take down unrelated apps.
The Worker fails to boot with an error naming node:async_hooks. You're
missing compatibility_flags = ["nodejs_compat"] in wrangler.toml. The adapter
imports node:async_hooks at the top level, so without the flag the Worker never
starts — it does not boot and serve a page with a missing binding. See the
"compatibility_flags = ['nodejs_compat'] is mandatory" warning earlier on
this page — that flag is a hard prerequisite for the adapter to thread env
into your SSR routes, not an optional opt-in.
The page renders but env.DB is undefined. The Worker booted (so
nodejs_compat is set), but the D1 binding isn't reaching it. Check that the
binding name in wrangler.toml (binding = "DB") matches the property you read
on env, and that you launched wrangler pages dev dist/ from the directory
whose wrangler.toml declares the binding.
The browser shows stale content after an edit. Usually the previous
pnpm build failed. Check the [watch] stream in the concurrently output for
a build error. The wrangler reload only fires when dist/ actually updates.
My cart / D1 data disappeared. The local SQLite DB lives under
. and persists across rebuilds. It resets if you
delete .wrangler/, switch to a different working directory, or change
database_name in wrangler.toml. Re-run
wrangler d1 migrations apply webshop --local after any of those to get a fresh
schema.
Why two processes, not one
zfb build is fast enough (sub-second on small projects) that running it on every save through a userland watcher is indistinguishable from a built-in--watch mode. The two-process recipe also keeps wrangler's worker-reload behaviour as a black box we don't have to re-implement.