zfb

Type to search...

to open search from anywhere

SSR and Cloudflare Bindings

CreatedJun 1, 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 a D1 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 intentional limitation of the dev-side SSR path today:

No live reload of the SSR bundle. Editing a prerender = false page’s source during a zfb dev session does not re-evaluate the bundle inside the running V8 host. Restart zfb dev to pick up the new code. (Live reload of the static-HTML cache works unchanged — only the SSR bundle path is restart-only.)

This limitation does not apply to the production Cloudflare adapter — which is what the dev server is mirroring. The point of dev parity is that the rendered output matches, not that every dev feature is the final shipping shape.

⚠️ 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 an AsyncLocalStorage (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_hooks as the missing module. See SSR 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 <code>AsyncLocalStorage</code> 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)

D1 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

wrangler pages dev dist/ runs the built _worker.js locally with a local D1 database (a SQLite file under .wrangler/). Apply your migrations to it with wrangler d1 migrations apply webshop --local before the first run.

Revision History