zfb
GitHub repository

Type to search...

to open search from anywhere

SSR and Cloudflare Bindings

Created Jun 24, 2026Takeshi Takatsudo

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/_worker.js — 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 build

    • deploy.

  • 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 correctly

These 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 your prerender = false routes.

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:

  1. Create the databasewrangler d1 create webshop. This prints the database_id; paste it into wrangler.toml.

  2. Write migrations — put .sql files under migrations/ (the wrangler default). Each migration is plain SQL — CREATE TABLE, etc.

  3. Apply migrationswrangler d1 migrations apply webshop (add --local for the local dev database, --remote for the deployed one).

  4. Deployzfb build then deploy dist/ 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 for prerender = false routes through the embedded V8 isolate (see dev-prod parity for prerender = false above), but it exposes no Worker bindings: calling getCloudflareContext<Env>() from an SSR route under zfb dev yields no env, so any route that reads env.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.js against a local D1 database (a SQLite file under .wrangler/), serves your real compatibility_flags, and surfaces real binding bugs. Use this whenever you're working on an SSR route that reads env.

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 --local

The webshop argument matches database_name in your wrangler.toml. This creates .wrangler/state/v3/d1/... 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-cli

Then 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:cf

Edit 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 .wrangler/state/v3/d1/... 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.

Revision History

Takeshi TakatsudoCreated: 2026-06-25T05:17:25+09:00Updated: 2026-06-25T05:17:25+09:00

AI Assistant

Ask a question about the documentation.