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 aCloudflare Worker with Static Assets using@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/ — 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.
The default export receives props, not the Request
Every page's default export — SSG or SSR — is called with the page's props object, never the incoming Request. prerender = false changes when the page renders (per request instead of once at build time), not what its default export is called with:
{}— a static route with nogetStaticProps()export.{ params }— a dynamic route with nopaths()export (a per-request page whose slugs can't be enumerated ahead of time).{ params, ...props }from the matchingpaths()entry, or the props returned bygetStaticProps(), otherwise.
A handler that declares a parameter meant to receive the Request receives one of the shapes above instead — and the mistake compiles cleanly, because Request is a valid TypeScript annotation for a parameter that actually receives props. The failure is silent: a request.method !== "POST" check reads undefined, undefined !==
"POST" holds, and the route returns 405 on every request, including the ones using the correct method.
// pages/api/products.tsx — ❌ declares `request`, actually receives props
export const prerender = false;
export default async function Products(request: Request) {
if (request.method !== "POST") {
// request.method is undefined here — this branch runs on every
// request, so the route 405s unconditionally.
return new Response("Method Not Allowed", { status: 405 });
}
// ...
}// pages/api/products.tsx — ✅ no parameter; read the real Request via getCloudflareContext()
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
export default async function Products() {
const { request } = getCloudflareContext();
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
// ...
}If you don't need the incoming Request at all, dropping the parameter entirely — export default async function Products() — is enough. See A prerender = false route always returns 405 if you're debugging this symptom in an existing route.
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.
This parity guarantee is scoped to render code — it does not extend to Worker bindings. zfb dev exposes no env/ctx, so a route that calls getCloudflareContext() cannot exercise that call under zfb dev at all, parity or not. See Local development below for the loop that does cover bindings.
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,
_worker.js+_zfb_inner.mjs— the Worker entry (a thin wrapper plus the bundled SSR routes) that serves yourprerender = falseroutes, and<name>-<hash>.wasmfiles for every Wasm module imported by the SSR bundle (e.g.index_bg-a1b2c3d4.wasm— this is esbuild's own--asset-names=[name]-[hash]convention, not a zfb-specific scheme), plus.assetsignore. The ignore file lists_worker.js,_zfb_inner.mjs, and every emitted Wasm basename, so the asset server never serves server code or compiled modules as public files (a request for/would otherwise download it as plain text)._ worker. js
You deploy this dist/ as a Cloudflare Worker with Static Assets via wrangler deploy, driven by a wrangler.toml at your project root that points main at dist/ and hands the rest of dist/ to the built-in asset server. The Worker handles dynamic routes; the asset server handles everything else — including a public/_redirects file, if your project has one; see Static Assets — _redirects for the supported rule syntax.
Cloudflare Pages advanced mode is unverified
This adapter is verified on Workers Static Assets. The generated root-level _worker.js follows the Pages advanced-mode convention, but Cloudflare Pages advanced mode has not been verified for this adapter. Do not treat it as a supported deployment target until it has a dedicated smoke test.
The wrangler.toml
The deploy config lives in a wrangler.toml at your project root:
# wrangler.toml
name = "my-site"
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./dist"
binding = "ASSETS" # lets the Worker probe assets itself — see below
not_found_handling = "404-page"mainpoints wrangler at the emitted Worker entry.compatibility_datepins the Workers runtime behaviour.compatibility_flags = ["nodejs_compat"]is required — see the warning below.[assets] directory = "./dist"hands your static output to the built-in asset server.[assets] binding = "ASSETS"is recommended: it exposesenv.ASSETSto the emitted_worker.js, which probes it directly for GET/HEAD requests ahead of your SSR routes (canDelegateToAssets()inworker-wrapper.mjsno-ops the probe when this binding is absent). See SSR on a Worker — the in-Workerenv.ASSETSprobe is not dead code for why the probe still matters even though the platform's own asset router already serves most hits under the defaultrun_worker_first = false.not_found_handling = "404-page"is recommended: unmatched asset paths fall through to the Worker, which serves yourpages/and any dynamic404. tsx prerender = falseroute.
Do NOT set `not_found_handling = 'single-page-application'`
single-page-application makes the asset server return index.html for every unresolved path before the Worker ever sees the request — which silently breaks dynamic routes like pages/api/*.tsx. Use 404-page so unmatched paths reach the Worker.
Styled 404 vs a route's own 404
With not_found_handling = "404-page" and [assets] binding = "ASSETS" both set, an unmatched path can produce two different 404 responses before the client sees one: the asset layer's styled dist/ (built from pages/), and whatever the inner Worker itself returns for that same miss. _worker.js picks between them with a fixed precedence:
The inner Worker returns a non-404 — a genuinely dynamic route matched — and that response wins outright.
The inner Worker also 404s, but with only the framework's generic not-found body (Hono's default
text/plain"404 Not Found", or a bare 404 with nocontent-type) — the styleddist/wins, so visitors see your designed page instead of plain text.404. html The inner Worker 404s with any other
content-type— that response is deliberate and is returned unchanged: atext/html404 is aprerender = falseroute rendering its own not-found page, and anapplication/json404 is an intentional machine-readable API error.
If you hand-write a prerender = false route that returns its own 404s (an API endpoint, say), set content-type: application/json on them — a bare text/plain 404 is indistinguishable from the framework default and yields to the styled page. Under not_found_handling = "none" the asset 404 carries no styled body, so the inner Worker's 404 is always shown.
Running the Worker before assets (run_worker_first)
Every example on this page assumes the zfb default, run_worker_first = false: the platform's edge asset router serves a matching static file before _worker.js ever runs (see SSR on a Worker — the dispatch flow for the full mental model). Setting run_worker_first = true inverts that — every request, including hits on prerendered static pages, reaches the Worker first:
[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = trueThe cost: every request now pays for a Worker invocation, even the ones that used to be served for free straight off the edge — there is no more "static hits never touch the Worker" fast path. Reach for this only when a route genuinely needs to run ahead of static serving. The canonical case is a password/session gate: an SSR route checks a cookie on every request — including ones for prerendered pages — and redirects to a login page before the asset layer ever serves the protected content (see the password-gate example, which does exactly this).
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.
Importing Wasm in an SSR route
An SSR route (or any helper it imports) can default-import a .wasm file — see Importing WebAssembly for the adapter-agnostic mechanism (the default import resolves to a WebAssembly.Module, the ambient typing that makes that work, and the src/ bridge for Wasm-only helpers outside the @takazudo/zfb import graph). This section covers the Cloudflare-specific half: what the adapter emits and how Wrangler treats it.
zfb copies the module through the SSR bundle and exposes it to the Worker as a compiled Wasm module. A practical use case is OG-image generation with satori and @resvg/resvg-wasm: the example assumes loadOgFonts() is an app helper that returns Satori's font configuration.
// pages/api/og.tsx
import satori from "satori";
import { initWasm, Resvg } from "@resvg/resvg-wasm";
import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm";
export const prerender = false;
let resvgReady: Promise<void> | undefined;
function ensureResvgWasm(): Promise<void> {
return (resvgReady ??= initWasm(resvgWasm));
}
export default async function OgImage() {
await ensureResvgWasm();
const ogFonts = await loadOgFonts();
const svg = await satori(
<div style={{ color: "white", background: "black", padding: 48 }}>zfb</div>,
{
width: 1200,
height: 630,
fonts: ogFonts,
},
);
const png = new Resvg(svg).render().asPng();
return new Response(png, { headers: { "content-type": "image/png" } });
}See Ambient typing for why the default import already types as WebAssembly.Module and how the src/ bridge works for Wasm-only helpers outside the @takazudo/zfb import graph.
Emitted Wasm layout
The adapter copies each bundle-relative Wasm asset next to the Worker entry and records it in .assetsignore. A build that imports Resvg can look like:
dist/
_worker.js
_zfb_inner.mjs
index_bg-a1b2c3d4.wasm
.assetsignore_worker.js
_zfb_inner.mjs
index_bg-a1b2c3d4.wasmThe last block is the relevant part of dist/.assetsignore: the Wasm file is a Worker module, not a public static asset. The adapter merges these required entries with an existing ignore file rather than overwriting it.
Wrangler rules and size limits
zfb's tested Wrangler baseline is 4.85.0, and zfb preview enforces it as the minimum supported version: an older Wrangler aborts with upgrade guidance, while an equal or newer one proceeds (newer versions print an info line, or a warning on an untested major). Use the version in your project's lockfile (for example, through pnpm exec wrangler).
The standard route needs no custom [[rules]] block: Wrangler's default module rules classify imported .wasm files as CompiledWasm while it bundles main. Custom rules do not globally replace the defaults. The important exception is a same-type, non-fallthrough CompiledWasm rule: that rule suppresses the default Wasm rule, so add one only when its matching behavior is deliberate.
Compiled Wasm counts toward the compressed Worker package limit: 3 MiB on Workers Free and 10 MiB on Workers Paid. Check the gzip size with wrangler deploy --dry-run. When an image renderer or another Wasm dependency outgrows that package, move it to a separate Worker and call it through a service binding instead of growing the zfb Worker further:
[[services]]
binding = "OG_IMAGE"
service = "og-image"The SSR handler can then call env.OG_IMAGE.fetch(...); the heavy Wasm module stays in the dedicated Worker.
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, or under zfb dev: zfb dev runs the SSR render code, but it never establishes a Cloudflare request scope, so this call throws there too. SeeLocal development below for the loop that does give you a working request scope. That is by design: a route that needs bindings must export prerender = false and run under wrangler dev / zfb preview to actually reach them. 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 Worker through the same wrangler.toml. The binding name (DB below) is the property you read on env:
# wrangler.toml — add alongside the [assets] block above
[[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 build, thenwrangler deployto shipdist/as your Worker with Static Assets.
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>"Named environments are separate Workers
On Workers, a named environment like [env.preview] deploys as adistinct Worker — wrangler deploy --env preview ships amy-site-preview Worker with its own bindings and its own URL. This is not the same as a Cloudflare Pages preview deployment on a branch: the semantics differ, and the deeper details are out of scope here.
Reading a KV namespace (env.MY_KV)
is Cloudflare's eventually-consistent key-value store. A KV binding is exposed on env exactly like D1 above — the adapter does not treat it specially:
// pages/api/greeting.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false;
interface Env {
// `KVNamespace` comes from `@cloudflare/workers-types`.
MY_KV: KVNamespace;
}
export default async function Greeting() {
const { env } = getCloudflareContext<Env>();
const stored = await env.MY_KV.get("greeting");
return new Response(stored ?? "no greeting set yet");
}Writes use .put():
await env.MY_KV.put("greeting", "hello from KV");.get(key, "json") parses a value stored with JSON.stringify(...) back into an object.
Wiring up the KV binding
Like D1, a KV namespace is bound through wrangler.toml. The binding name (MY_KV below) is the property you read on env:
# wrangler.toml — add alongside the [assets] block above
[[kv_namespaces]]
binding = "MY_KV" # → env.MY_KV inside the Worker
id = "<namespace-id>" # printed by `wrangler kv namespace create`Create the namespace —
wrangler kv namespace create MY_KV. This prints the namespaceid; paste it intowrangler.toml.Read and write —
env.MY_KV.get(...)/.put(...)as shown above.Deploy —
zfb build, thenwrangler deploy.
Every other binding — R2 buckets, Workers AI, the Cache API, Durable Objects, service bindings — reaches an SSR route the exact same way: declare it in wrangler.toml, add its type to your Env interface, and read it off env inside getCloudflareContext(). See the Examples index for standalone KV, Workers AI, and reverse-proxy recipes.
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.)
For more Cloudflare recipes beyond webshop — Workers KV, Workers AI, a reverse proxy, a password gate, and more — see the Examples index of every standalone example repository.
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 devthrows because there is no Cloudflare request scope, 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 dev— 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. It discovers the Worker entry and asset directory from yourwrangler.toml(main+[assets]) — there is no positionaldist/argument. Use this whenever you're working on an SSR route that readsenv. (zfb previewwraps this same handoff: it runs a couple of pre-flight checks and then execswrangler devfor you.)
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 dev (which auto-reloads when its main entry or the assets under dist/ change) 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 dev --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 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 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 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.
zfb preview aborts with "adapter mode requires a wrangler config". In adapter mode zfb preview hands off to wrangler dev, which discovers the Worker entry and asset directory entirely from its own config — so zfb runs a pre-flight check that the config exists and bails with a clear error when it is missing:
preview: adapter mode requires a wrangler config at the project root
(wrangler.toml | wrangler.jsonc | wrangler.json), but none was found in <dir>.
Minimal example (wrangler.toml):
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./dist" Create the wrangler.toml shown under The wrangler.toml and re-run.
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. wrangler dev resolves both the Worker entry and its bindings from wrangler.toml (main, [assets], and [[d1_databases]]), so check that the binding name there (binding = "DB") matches the property you read on env, and that you launched wrangler dev from the project root 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.