zfb
GitHub repository

Type to search...

to open search from anywhere

CLI Reference

Complete reference for the zfb command-line interface — subcommands, flags, and environment variables.

Overview

The zfb binary exposes seven top-level commands for the full development lifecycle:

zfb new    — scaffold a new project from a template
zfb dev    — local development server with live reload
zfb build  — production build
zfb css    — compile a CSS entrypoint without building the site
zfb wind   — inspect utility candidates and source usage
zfb preview — serve a previously built site
zfb check  — typecheck and validate content-collection schemas

Run zfb --help or zfb <command> --help to see the generated help text directly.

For the everyday sequence, see Build with zfb. There are no test, lint, format, deploy or upgrade subcommands; use your project and hosting tools for those tasks.

zfb new

Scaffold a new project from a built-in template.

zfb new <name> [--template <template>]

Arguments

NameTypeRequiredDescription
namepositionalyesName of the new project. Used as the destination directory.
--templatestringnoTemplate to scaffold from. Default: basic-blog.

Templates

v0 ships two templates, both baked into the binary at compile time from crates/zfb/templates/:

  • basic-blog (default) — full-featured blog scaffold with package.json; zfb new runs pnpm install for it automatically when pnpm is on PATH.

  • node-free — ships no package.json and skips the post-scaffold pnpm install step entirely, for projects run with no Node/pnpm on PATH. See Markdown and HTML Pages for the .md/.html page-authoring surface, and Install without Node for installing the zfb binary itself without Node.

Example

zfb new my-site
zfb new my-site --template basic-blog
zfb new my-site --template node-free

zfb dev

Start the local development server with live reload.

zfb dev [--port <port>] [--host [<host>]]
        [--scratch-dir <path>] [--define <KEY=EXPR>]...

Flags

FlagTypeDefaultDescription
--portu163000*Port to bind the dev server to.
--hoststringlocalhost*Host interface to bind to. Bare --host (no value) is a shortcut for 0.0.0.0.
--scratch-dirpathZFB_SCRATCH_DIR / .zfb-buildGive this dev server its own directory for zfb's generated artifacts. Under a scratch dir, dev ignores the prebuilt output directory. See Scratch dirs.
--defineKEY=EXPR, repeatablenoneAdd an esbuild define for this invocation, overriding bundle.define per key. See Per-invocation defines.

* Falls back through: CLI flag → port/host in zfb.config.json → built-in default.

Lazy rendering

The dev server defaults to lazy rendering: changed routes are marked stale and re-rendered on the first request rather than immediately on every file change. This keeps the inner loop fast for large sites.

To restore the pre-lazy behavior and re-render every affected route eagerly on each file change, set ZFB_DEV_EAGER=1.

Environment variables

VariableValuesDescription
ZFB_DEV_EAGER1Disable lazy dev rendering. Re-renders every affected route eagerly on each file change.
ZFB_LAZY_DEV_RENDER0 or 1Precise override of the lazy dev-render switch. 1 forces lazy; 0 forces eager. Takes precedence over ZFB_DEV_EAGER when both are set.
ZFB_DEV_BOOT_LAZY1Legacy seed-based fast boot: use prebuilt dist/ and render routes on their first request. Without a servable seed this explicit mode falls back to eager startup. Requires lazy rendering. Omit the variable for the default seedless lazy startup.
ZFB_DEV_BOOT_LAZYcold (default)Seedless lazy startup: prepare the renderer and required assets, then render each route on request. Unrequested pages do not delay early file edits. Before the renderer is available, routes without a fallback return the dev 404 with live reload; a prebuilt dist/ can still supply a fallback. Each process uses a fresh HTML output directory, so previous-session dev HTML is never served. Set 0 or false for eager all-page startup, or ZFB_DEV_EAGER=1 to disable lazy rendering entirely.
ZFB_DEV_DEFER_BUNDLE0Opt out of the boot-lazy bundle deferral (either variant): build the renderer before bind (no SSR-only 404 window) at the cost of a slower first-accept. On by default when boot-lazy is active.
ZFB_DEV_TIMING1 or trueEmit per-tick timing lines for dev-mode profiling. Off by default.
ZFB_KEEP_SHADOW_SESSION1 or trueKeep the dev server's persistent shadow tempdir on disk instead of deleting it when the process exits — for inspecting the generated shadow tree (injected-route shims, resolved node_modules layout). The kept path is logged once at shutdown. A later zfb dev boot with the flag unset reaps a previously-kept dir once it passes an internal age floor, so keep the flag exported for the whole debugging session, not just the boot that created the dir. See ZFB_KEEP_BUILD_SHADOW under zfb build for the production-build counterpart — a different tempdir class with no such reap. Off by default.

Example

# Default — binds localhost:3000
zfb dev

# Custom port
zfb dev --port 8080

# Expose to the LAN (0.0.0.0)
zfb dev --host

# Explicit host
zfb dev --host 192.168.1.10 --port 4321

zfb build

Build the project for production. Emits static HTML (and, when an adapter is configured, a server entry) to the output directory.

zfb build [--outdir <dir>] [--minify-html | --no-minify-html]
          [--strict-broken | --no-strict-broken]
          [--strict-content-bridge | --no-strict-content-bridge]
          [--strict-plain-css-imports | --no-strict-plain-css-imports]
          [--emit-render-artifacts | --no-emit-render-artifacts]
          [--scratch-dir <path>] [--warnings-json <file>]
          [--define <KEY=EXPR>]...

Flags

FlagTypeDefaultDescription
--outdirpathconfig / distOutput directory for the production build. Overrides config outDir; when both are absent, uses dist.
--minify-htmlboolean flagconfig/defaultEnable production HTML minification for this build. Overrides minifyHtml: false or an omitted config value.
--no-minify-htmlboolean flagconfig/defaultDisable production HTML minification for this build. Overrides minifyHtml: true from config or presets.
--strict-brokenboolean flagconfig/defaultFail this build (non-zero exit) when markdown link validation finds a broken link. Overrides strictBrokenLinks: false or an omitted config value. On a project with no linkValidation config at all, it force-enables link validation with its defaults rather than doing nothing.
--no-strict-brokenboolean flagconfig/defaultDo not fail this build on broken links. Overrides strictBrokenLinks: true from config or presets. Disables the top-level strict overlay only — a linkValidation block that explicitly sets failOnBroken: true stays in effect.
--strict-content-bridgeboolean flagconfig/defaultFail this build (non-zero exit) when a content-collection .md/.mdx entry's compiled JSX cannot be parsed for the MDX bridge and would render <pre data-zfb-content-fallback>. Overrides strictContentBridge: false or an omitted config value.
--no-strict-content-bridgeboolean flagconfig/defaultDo not fail this build on an MDX content-bridge fallback; zfb still warns and emits the fallback. Overrides strictContentBridge: true from config or presets.
--strict-plain-css-importsboolean flagconfig/defaultFail this build when esbuild resolves a plain .css import from JavaScript/TypeScript whose bytes are not emitted. Overrides strictPlainCssImports: false or an omitted config value.
--no-strict-plain-css-importsboolean flagconfig/defaultContinue with warnings for dropped plain CSS imports. Overrides strictPlainCssImports: true from config or presets.
--emit-render-artifactsboolean flagconfig/defaultWrite a JSON render artifact under __zfb/render/ for every markdown/MDX-backed HTML route whose page renders exactly one top-level content region. Overrides emitRenderArtifacts: false or an omitted config value.
--no-emit-render-artifactsboolean flagconfig/defaultDo not write render artifacts for this build. Overrides emitRenderArtifacts: true from config or presets.
--scratch-dirpathZFB_SCRATCH_DIR / .zfb-buildGive this build its own directory for zfb's generated artifacts. The output directory is still shared. See Scratch dirs.
--warnings-jsonpathnoneWrite build diagnostics to a schemaVersion 1 JSON file. Failed build reports may be partial.
--defineKEY=EXPR, repeatablenoneAdd an esbuild define for this build, overriding bundle.define per key. See Per-invocation defines.

HTML minification is off by default unless minifyHtml: true is set or --minify-html is passed. It runs inside zfb's Rust post-processing pipeline and does not spawn a Node.js minifier subprocess. The first version is conservative: rendered HTML pages are candidates, source .html passthrough pages remain verbatim, and non-HTML outputs are skipped.

Strict broken-link gating is likewise off by default. --strict-broken and --no-strict-broken conflict — passing both is an error — and either one overrides the strictBrokenLinks config field, which in turn overrides the false default. Both flags affect zfb build only; zfb dev never sees the strict overlay and keeps whatever link-validation behavior your own linkValidation config gives it. See Link validation — Strict mode for the full behavior, including how this differs from resolveMarkdownLinks.onBrokenLinks.

The MDX content-bridge and plain-CSS import gates are also off by default. Each positive and negative flag pair conflicts if both flags are passed; either explicit flag overrides its matching config field, which defaults to false. These flags affect zfb build only. zfb dev still warns and serves an MDX fallback, and keeps plain-CSS import warnings non-fatal. See define-config for the config fields.

Render-artifact export is likewise off by default. --emit-render-artifacts and --no-emit-render-artifacts conflict — passing both is an error — and either one overrides the emitRenderArtifacts config field, which in turn overrides the false default. Both flags affect zfb build only; zfb dev never writes render artifacts. See Render artifacts for the full JSON contract, the region rule, and which routes participate.

Environment variables

VariableValuesDescription
ZFB_KEEP_BUILD_SHADOW1 or trueLeave this build's shadow tempdirs on disk after the process exits — on a failing build too — instead of deleting them, for inspecting the generated shadow tree (injected-route shims, resolved node_modules layout) without an external polling harness. Covers exactly three tempdir classes: the bundler's shadow root, its exact-node_modules isolation root, and the package-routes overlay (only allocated when the project registers package routes). Each kept path is printed once to stderr, naming its role. Other zfb build temp producers (config loading, islands/esbuild, CSS, plugin resolution) are not covered. A kept dir is never auto-reaped — cleaning it up is your responsibility (contrast ZFB_KEEP_SHADOW_SESSION under zfb dev, which a later boot eventually reaps). zfb dev is entirely unaffected by this flag, even where it happens to share an allocator with zfb build. Off by default.

Example

zfb build
zfb build --outdir public
zfb build --minify-html
zfb build --no-minify-html
zfb build --strict-broken
zfb build --no-strict-broken
zfb build --strict-content-bridge
zfb build --no-strict-content-bridge
zfb build --strict-plain-css-imports
zfb build --no-strict-plain-css-imports
zfb build --emit-render-artifacts
zfb build --no-emit-render-artifacts
zfb build --scratch-dir .zfb-build/ci --define __ORIGIN__='"https://www.example.com"'

Build warning JSON

zfb build --warnings-json warnings.json writes schemaVersion: 1, command: "build", status, and diagnostics. Status is success, failed, or incomplete; the latter two can contain only the diagnostics observed before termination. Records are sorted deterministically, and repeated occurrences remain separate. Warnings do not change existing exit behavior.

A successful build with no diagnostics writes diagnostics: []. Early build failures write a failed report with the diagnostics available so far, which may be empty. The report path's parent directory must already exist; a write failure is reported and makes the build command fail.

Each record has code, severity, and message. sourceId, file, line, and byteColumn are optional. byteColumn is a one-based UTF-8 byte column; sourceId is opaque and is not necessarily a path. Use codes rather than message text in automation, and allow unknown future codes.

CodeClass
ZB001Broken Markdown link
ZB002Image dimension probe
ZB003Plain CSS input omitted
ZB004Dangling symlink skipped
ZB005Foreign JSX import-source pragma
ZB006Raw plugin-host output
ZB007Unsupported collection file
ZB008Mirror preprocessing fallback
ZB009Generic Markdown diagnostic
ZB010Plugin logger message
ZB011Transclusion diagnostic
ZB012Directive diagnostic
ZB013Invalid MDX spread attribute
ZB014Astro client attribute has no island activation effect
ZB015Island scanner failure
ZB016No Island target found
ZB017Embedded tool fallback
ZB018Prerender data extraction fallback
ZB019SSR route contract finding
ZB020Empty route set
ZB021Raw adapter stderr
ZB022Invalid asset output path
ZB023Deferred dynamic route
ZB024Content snapshot fallback
ZB025Render artifact failure
ZB026Opt-in snapshot probe failure
ZB027Public file copy warning
ZB028Dev Island rebundle warning
ZB029Dev client script cleanup
ZB030Dev client script name collision
ZB031Markdown frontmatter fallback

ZW001–ZW014 retain their existing Wind meanings. Wind sourceId values are opaque. Audit-only ZW015 and ZW016 do not appear in build warning JSON.

zfb css

For in-process Node.js compilation with in-memory configuration and optional output, use compileCss. The CLI delegates to the same compiler.

Compile one CSS entrypoint with zudo-wind and zfb's authored-CSS processing, without discovering routes, rendering pages, bundling application code, or starting the plugin host.

zfb css --input <entry.css> --output <compiled.css>
        [--project-root <dir>] [--config <file>]
        [--source <glob> ...]
        [--no-auto-source]
        [--code-highlight-mode <class|inline>]
        [--no-default-highlight-styles]

Flags

FlagTypeDefaultDescription
--inputpathrequiredCSS entrypoint. Relative paths resolve from the current working directory.
--outputpathrequiredDestination CSS file. Relative paths resolve from the current working directory. It must not resolve to the same file as the input.
--project-rootpaththe --config file's directory, else the current working directoryRoot used to discover zfb.config when --config is omitted, and to resolve --source globs and the conventional source roots. A relative path itself resolves from the current working directory.
--configpathnonezfb config file (.ts, .mts, .js, .mjs, or .json) to load instead of discovering zfb.config.ts or zfb.config.json in the project root. A relative path resolves from the current working directory. See Explicit config files.
--sourceglob, repeatablenoneExplicit zudo-wind candidate source, resolved from --project-root. Each glob must match at least one file. Matches under the configured outDir, at the --output path, under zfb scratch roots, or matching a wind.sources.exclude pattern are excluded. A declaration whose matches are all excluded fails with a ZW010 error; a partially excluded declaration reports a ZW010 warning and keeps its accepted matches. Caller order is preserved; exact duplicates use their first occurrence. The standalone command accepts .html and .mjs sources as well as the build/dev source extensions.
--no-auto-sourceboolean flagfalseOmit the command's conventional source-plan roots. Explicit --source values and the configured syntax-highlight role classes remain active.
--code-highlight-modeclass or inlineconfig/defaultOverride codeHighlight.mode when deciding whether to include zfb's framework syntax-highlight CSS. Other codeHighlight settings, including classPrefix and roleClasses, still come from config.
--no-default-highlight-stylesboolean flagfalseSuppress zfb's built-in class-mode zfb-hi.css stylesheet. This also overrides codeHighlight.defaultStylesheet: true.

Example

A project config supplies the utility values, and a source glob finds candidates in the project tree. This example also imports a CSS subpath exposed by an installed package's exports map (@example/design-system):

import { defineConfig } from "zfb/config";

export default defineConfig({
  wind: {
    tokens: {
      spacingUnit: "0.25rem",
      colors: {
        brand: "oklch(0.65 0.2 250)",
      },
      radii: {
        card: "0.5rem",
      },
    },
    authoredClasses: {
      example: true,
    },
    manifests: {
      "design-system": { path: "@example/design-system/wind.json" },
    },
  },
});

The installed package exposes ./base.css and ./wind.json. Its dist/wind.json contains:

{
  "schemaVersion": 1,
  "specVersion": 1,
  "producer": "design-system",
  "candidates": ["bg-brand", "p-4", "rounded-card"]
}

Those package candidates use tokens configured by the project.

export function ExampleCard() {
  return <article className="example flex p-4 bg-brand rounded-card">Example</article>;
}

The entry uses only authored CSS and @import; it has no Tailwind directives:

@import "@example/design-system/base.css";

.example {
  border: 1px solid var(--zw-color-brand);
}

Run the command from the project root. The glob scans this project's source files; the configured manifest adds the design-system package's candidates:

zfb css \
  --input src/styles/entry.css \
  --output dist/app.css \
  --project-root . \
  --source 'src/**/*.{tsx,ts,jsx,js}' \
  --no-auto-source

The @import paths resolve as authored CSS. zudo-wind gets utility values from zfb.config.ts, local candidates from --source, and package candidates from wind.manifests.

Notes

  • Sources follow the configured source plan. --source supplies explicit inputs; --no-auto-source omits the command's conventional roots while preserving explicit inputs and the classes configured in codeHighlight.roleClasses. Walking a conventional root skips subdirectories named node_modules, dist, build, target, .git, .cache, .turbo, .next, .vercel, .zfb-build, and .zfb; files an explicit --source glob matches in such directories are still read. Declare a package source root with wind.sources.packageRoots or --source, or publish its candidates through wind.manifests, and remove files from every root with wind.sources.exclude. The consuming project's configured outDir, the --output path, and zfb scratch roots stay excluded. If every match from an explicit --source is under one of those output roots or matches a wind.sources.exclude pattern, zfb css reports a ZW010 error; if only some matches are excluded, it reports a ZW010 warning and scans the accepted files. For example, --source 'dist/**/*.tsx' reports an error when dist is the configured outDir. Build and dev scan .tsx, .ts, .jsx, .js, .mdx, and .md by default; standalone explicit inputs can also use .html and .mjs. See Sources and candidates for source-plan details.

  • Output is deterministic and replaced atomically. The same entry, config, and ordered source set produce timestamp-, sourcemap-, and temporary-path-free bytes. A successful compile atomically replaces --output; a failed compile leaves any existing output untouched.

  • This is a global-CSS-only command. CSS Modules are not discovered or processed. Package-attributed url() assets that would require companion files are rejected in v1 with a non-zero exit instead of being emitted beside the stylesheet.

  • Config use is narrow. When present, the --config file or the project's zfb.config.json or zfb.config.ts supplies wind tokens, reset, sources, and syntax-highlighting settings such as classPrefix and roleClasses; CLI highlight flags override the corresponding mode/stylesheet choice. wind: false disables wind generation while authored CSS and highlight processing continue. An omitted key uses the empty enabled wind config.

  • V1 wind output is not minified. Generated wind CSS uses the fixed printer described in Cascade and reset.

Explicit config files

A package that ships a stylesheet has no site config of its own, but its classes use the tokens of the configuration its consumers run. --config <file> loads that configuration with the normal config loader, including presets, without making the package look like a site:

cd packages/ui
zfb css \
  --input src/styles/entry.css \
  --output dist/ui.css \
  --config ../../site/zfb.config.ts \
  --project-root . \
  --source 'src/**/*.tsx' \
  --no-auto-source

Two directories are involved:

  • The config file's directory anchors every reference written in the config: relative wind.manifests paths, wind.sources.packageRoots, and wind.sources.exclude patterns. A preset keeps its own package directory as the base for the references it declares, and preset packages resolve from the config file's directory.

  • The project root anchors the command's own inputs: --source globs, the conventional source roots, and the configured outDir exclusion. Without --project-root, it is the config file's directory.

zfb wind explain and zfb wind audit accept the same --config flag, so one configuration file can drive the compile, the audit, and candidate checks. Without --config, every command behaves as before and discovers zfb.config.ts or zfb.config.json in the project root.

zfb wind

Use zfb wind explain to inspect how a candidate is parsed, resolved, and emitted. The explanation includes its catalog entry, token bindings, declarations, selector, conditions, and ordering data.

zfb wind explain [--project-root <path>] [--config <file>] [--json] <candidate>
zfb wind explain [--project-root <path>] [--config <file>] [--json] --stdin
zfb wind explain -- -mt-4

--config <file> loads a zfb config file instead of discovering one in the project root, as described in Explicit config files. A candidate that starts with a dash, such as a negative utility, must follow -- so it is not read as an option. --stdin reads one complete candidate per nonempty line and explains them all with one configuration load, in input order and keeping duplicates. --json prints { "schemaVersion": 1, "command": "explain", "explanations": [...] } on stdout and nothing else.

Use zfb wind audit to review ordinary class-position names, missing or unused utilities, dynamic class fragments, and property conflicts within a single class-list literal. An exact authoredClasses match and group/peer markers are excluded. With wind: false, explain reports generation disabled and audit returns an empty candidate report.

zfb wind audit [--project-root <path>] [--config <file>] [--fail-on error|warning]
               [--plan standalone|build]
               [--severity error|warning|auditInfo] [--group] [--json]

--plan standalone (the default) audits the same roots as zfb css without --source: the conventional project roots plus configured wind.sources package roots and exclusions. --plan build audits the plan zfb build and zfb dev scan. It runs plugin setup to discover package-owned routes, sibling mirror roots, and plugin virtual modules, which are audited as plugin/<specifier>. It runs no preBuild or postBuild hook and writes no build output. Either mode first prints wind audit plan: <mode> followed by every scanned root, virtual module, manifest, and exclusion with its reason.

--severity shows only diagnostics at or above that level; it filters the output and never changes the exit status. --group is text-only: it summarizes unrecognized classes by candidate, and dead classes and diagnostics by candidate, code, and severity, with an occurrence count and first origin. The first origin is the earliest source location in the deterministic report order; diagnostic summaries retain that occurrence's message and optional suggestion. --group does not change the default text format or exit status. --json prints { "schemaVersion": 1, "command": "audit", "severity", "coverage", "report" } on stdout instead of text; combining --group with --json leaves the JSON document and its occurrence arrays unchanged. The coverage object lists the plan mode, roots, virtual modules, manifests, and exclusions. Each origin keeps sourceId, a one-based line, a one-based byte column byteColumn, and the byteOffset separately; fields that do not apply are null. Status notes and failures still go to stderr.

A complete audit exits with status 0 by default, including when it reports error-severity findings; in that case stderr suggests --fail-on error. Invalid configuration or a source scan failure exits nonzero. Use --fail-on error to exit nonzero when the report contains error-severity diagnostics, or --fail-on warning to include warnings. auditInfo findings never trigger either threshold. With wind: false, the generation-disabled report exits with status 0.

These commands inspect the configured project; they do not execute application code to discover class candidates, apart from plugin setup under --plan build. See Diagnostics and tools for outcome and origin details.

zfb wind manifest

Generate the candidate manifest a package publishes for its consumers, using the same source plan, extractor, and compiler as zfb css:

zfb wind manifest --producer <id> --output <file>
                  [--source <glob> ...] [--no-auto-source]
                  [--project-root <dir>] [--config <file>] [--json]

--producer is the manifest's producer id ([A-Za-z0-9][A-Za-z0-9._/-]*); consumers declare the file under this key in wind.manifests. --output resolves from the current working directory. --source, --no-auto-source, --project-root, and --config work as they do for zfb css; see Explicit config files.

Every candidate occurrence in the scanned sources is classified in one compile:

  • Resolved utilities and group/peer markers form the manifest, sorted by byte order and deduplicated.

  • authoredClasses matches and ordinary words are left out and counted in the report.

  • Low-confidence words at string-literal positions that do not resolve are left out and counted.

  • Errors, such as an unknown token at a class position, fail the command. Nothing is written and an existing --output file keeps its contents, so a manifest can never silently omit a style the package needs.

Configured wind.manifests entries, including one that names this command's own output, are neither read nor copied, and neither are wind.safelist entries or highlight role classes. Only candidates from the scanned sources reach the manifest. The --output path is excluded from the source plan. A successful run atomically writes the existing manifest schema, and the same sources and config produce the same bytes:

{
  "schemaVersion": 1,
  "specVersion": 1,
  "producer": "ui",
  "candidates": ["bg-surface", "group", "p-hsp-sm"]
}

The text report goes to stdout: the plan, the candidate and marker counts, the excluded counts, and each error or warning with its source location. --json prints { "schemaVersion": 1, "command": "manifest", "producer", "output", "written", "coverage", "planDiagnostics", "classification" } instead. classification lists the candidates, markers, excluded authored classes and ordinary words with their origins, and every diagnostic. The exit status is nonzero when the manifest was not written.

A manifest records the token contract it was validated against. Classification uses the tokens, breakpoints, and dark mode of the selected config. A consumer must configure every token and breakpoint those candidates use (for example surface in colors for bg-surface), because the consumer validates each manifest entry strictly under its own config. A manifest that passes under the package's config is not guaranteed to resolve under a consumer config that lacks those tokens. Document the required tokens alongside the package, or ship them as a preset.

zfb preview

Serve a previously built site locally. Run zfb build first.

zfb preview [--port <port>] [--host [<host>]] [--outdir <dir>]
            [--scratch-dir <path>]

In static mode (no adapter, or adapter: "none"), zfb preview also runs the SAME two request-time layers zfb dev does, ahead of the plain static-file waterfall: a plugin's previewMiddleware registrations, then public/_redirects (see Static Assets — _redirects). A plugin claiming a URL always wins over a _redirects rule for that same URL.

Only GET/HEAD requests reach the _redirects matcher — mirrors real Workers Static Assets, which only ever probes the asset layer (and therefore _redirects) for those two methods. Any other method either reaches a plugin handler (which accepts every method) or 405s; a rule that would otherwise fire for GET /old-page never fires for POST /old-page.

_redirects and a custom `base`

Static preview serves whatever zfb build emitted under the selected output directory, and does not know about your project's base prefix — it has no prefix-stripping layer of its own. So _redirects rule sources are matched against the full request path, exactly as written in the file — they are never rewritten to add or strip a base prefix. zfb dev mirrors that matching shape even though its router is mounted under base. If zfb.config.ts sets base: "/pj/site/", a rule meant to redirect /pj/site/old-page must be written with the prefix included (/pj/site/old-page /pj/site/new-page 301), not as /old-page /new-page 301.

When zfb.config.json's adapter field names a supported adapter (e.g. @takazudo/zfb-adapter-cloudflare), zfb preview hands off to wrangler dev instead of zfb's own static server — the output directory selected from --outdir or config outDir then only pre-checks that the directory exists, since wrangler's own config controls what it actually serves. See SSR and Cloudflare Bindings.

Adapter preview runs a pre-flight pnpm exec wrangler --version check before handing off, treating zfb's tested baseline 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). Set ZFB_SKIP_WRANGLER_VERSION_CHECK=1 only as a temporary escape hatch (e.g. if wrangler's version output format changes).

Adapter mode: zfb serves nothing

Under adapter mode, wrangler dev serves the whole site — zfb never boots its own router, and no plugin host is spawned to check for previewMiddleware at all. _redirects still works there, but it's wrangler/Workers Static Assets honouring the file natively, not zfb's _redirects engine. A previewMiddleware registration has no wrangler equivalent and simply does not run under adapter-mode preview — if your project has plugins configured, zfb preview prints a one-time warning to that effect before handing off.

Stopping an adapter-mode preview

In an interactive terminal, zfb hands the terminal to wrangler dev, so Wrangler's hotkeys and Ctrl+C work as usual. When zfb receives SIGINT, SIGTERM, or SIGHUP, it forwards that signal to the entire wrangler dev process tree it started (pnpm → node → wrangler → workerd), which runs in its own process group. zfb waits up to 5 seconds for the tree to stop, then force-kills anything still running. This frees the preview --port.

After a signal, zfb exits without an error banner using 128 + signal: 130 for SIGINT, 143 for SIGTERM, or 129 for SIGHUP. If wrangler dev fails on its own, zfb instead reports wrangler dev exited with status … and exits with status 1.

SIGKILL sent to zfb cannot be intercepted, so zfb cannot clean up the process tree in that case. A signal sent only to an outer wrapper that does not forward it, such as the PID of pnpm exec zfb preview …, never reaches zfb; scripts should stop the zfb process itself. Process-tree cleanup is not yet implemented on Windows.

Flags

FlagTypeDefaultDescription
--portu164321*Port to bind the preview server to.
--hoststringlocalhost*Host interface to bind to. Bare --host (no value) is a shortcut for 0.0.0.0.
--outdirpathconfig / distDirectory to serve the built artifacts from. Overrides config outDir; when both are absent, uses dist.
--scratch-dirpathZFB_SCRATCH_DIR / .zfb-buildValidate and lock a scratch dir for this preview. zfb writes nothing of its own there; plugins get its plugins/ child as ctx.scratchDir. --define is not accepted, because preview bundles nothing. See Scratch dirs.

* Falls back through: CLI flag → port/host in zfb.config.json → built-in default.

Output-directory precedence is likewise CLI --outdir → config outDir → built-in dist.

Example

zfb build && zfb preview
zfb preview --port 8080
zfb preview --host --outdir public

zfb check

Typecheck the project and validate content-collection frontmatter against their schemas.

zfb check [--skip-tsc] [--scratch-dir <path>] [--define <KEY=EXPR>]...

This command checks TypeScript, collection schemas, and SSR routes, and reports advisory JSX pragmas:

  1. TypeScript — invokes tsc --noEmit as a subprocess. Catches type errors in zfb.config.ts, collection schemas, and src/.

  2. Collection schema validation — validates every collection entry's frontmatter against the JSON Schema declared in each collection's schema field in zfb.config.json/zfb.config.ts. The build itself does not validate frontmatter, so this is the enforcement gate.

The TypeScript step uses the project's tsconfig.json, so a full zfb check requires one. Use --skip-tsc to run the other checks without it.

Either failure mode exits with a non-zero code.

The ZB005 advisory scans authored .tsx and .jsx files throughout the project, including components outside pages/; it excludes dependencies, generated output, scratch directories, and symlinks. It runs with --skip-tsc and never fails the check by itself. The SSR route-contract check also runs where routes exist: strong findings fail, while heuristic findings warn.

Flags

FlagTypeDefaultDescription
--skip-tscboolfalseSkip the tsc --noEmit subprocess. Schema validation, route checks, and JSX pragma advisories still run. Useful when TypeScript is not installed.
--scratch-dirpathZFB_SCRATCH_DIR / .zfb-buildValidate and lock a scratch dir. check writes nothing into it; the flag exists so one argument list or one exported ZFB_SCRATCH_DIR works for every command. See Scratch dirs.
--defineKEY=EXPR, repeatablenoneValidated only. The value does not reach tsc. See Per-invocation defines.

Example

# Full check — tsc + schema validation
zfb check

# Check without tsc
zfb check --skip-tsc

Tip

zfb check is the recommended way to validate content collections in CI. Wire it after zfb build to catch frontmatter violations that the build silently ignores.

Scratch dirs

By default, every command writes its generated artifacts to fixed locations in the project: the bundle and dev files under <root>/.zfb-build/, and the dev server's dependency graph at <root>/.zfb/graph.bin. Two commands running at the same time in one checkout therefore share those files. A zfb build can overwrite the bundle a running zfb dev is serving, and two dev servers overwrite each other's bundle and persisted dependency graph. --scratch-dir <PATH> gives one invocation its own directory for these artifacts, so concurrent commands stay apart. Give each concurrent command its own scratch dir.

# Two dev servers and a build in one checkout, each with its own scratch dir
zfb dev --port 3000 --scratch-dir .zfb-build/dev-main
zfb dev --port 3001 --scratch-dir .zfb-build/dev-preview
zfb build --scratch-dir .zfb-build/build

# The same through the environment
ZFB_SCRATCH_DIR=.zfb-build/dev-preview zfb dev --port 3001

Selecting a scratch dir

  • --scratch-dir is accepted by zfb dev, zfb build, zfb check, and zfb preview.

  • The flag wins over the ZFB_SCRATCH_DIR environment variable. An empty or whitespace-only ZFB_SCRATCH_DIR counts as unset.

  • A relative path resolves against the current working directory, which is the project root. zfb creates the directory if it is missing, but only after the path passes the rules below.

  • With neither the flag nor the variable, zfb uses the default <root>/.zfb-build/ layout exactly as before. It takes no lock and creates nothing extra.

Where to put it

The recommended location is <root>/.zfb-build/<name>. The scaffold templates already gitignore .zfb-build/, and every zfb source walker already skips it.

Inside the project, a scratch dir must sit strictly under .zfb-build/. Any other in-project location is rejected. A location outside the project, such as /tmp/zfb-dev-main, is allowed as long as it passes the same rules.

Each of these locations is a hard error. The message names the rule tag and the paths involved:

TagRejected location
R1The project root itself, or any ancestor of it.
R2An in-project path that is not strictly under .zfb-build/, including .zfb-build itself. Also any path below .zfb-build/ with a component named dev-pages, dev-assets, watcher-liveness-probe, or plugins, because those are the default layout's own directories.
R3A path that overlaps the command's output directory (--outdir, else config outDir) in either direction: inside it, or containing it.
R4A path that overlaps publicDir in either direction.
R5A path that overlaps an authored watch root in either direction. Watch roots are the default roots (pages, content, components, layouts, styles, data, src, and the zfb.config.* files), content collection directories, and extraWatchPaths.
R6A path nested inside another scratch dir, which is any directory above it that holds a .zfb-scratch.lock. Also a path with a component that zfb's own temp cleanup deletes: one starting with zfb-shadow-session-, or one named watcher-liveness-probe or zfb-dev-watcher-liveness-probe.

zfb compares paths after resolving symlinks, so a symlinked alias does not get past a rule. The rules are checked once before the directory is created and again on the created directory. zfb does not detect the reverse of R6: claiming a directory that already contains another scratch dir. Keep scratch dirs as siblings, such as .zfb-build/dev-main next to .zfb-build/build, rather than nesting them.

Ownership and locking

While a command runs, it holds an exclusive lock on <scratch>/.zfb-scratch.lock:

  • A second command on the same dir fails fast. It exits with an error saying that the scratch dir is in use by another zfb process. It never waits and never shares the directory.

  • The lock file marks the directory as zfb's. A later command may reuse a directory that has this file. An empty directory is also accepted. A non-empty directory without the lock file is refused, so a typo like --scratch-dir ~/Documents cannot write build artifacts into your own files.

  • The lock needs a local filesystem. If the filesystem does not support advisory locks, the command fails instead of running without exclusivity.

zfb never deletes a scratch dir

zfb never removes a scratch dir and never reaps old ones. The lock file stays in place after the command exits. Delete scratch dirs you no longer need yourself, after every command using them has stopped.

What lives inside

EntryWritten by
.zfb-scratch.lockEvery command that uses the scratch dir.
bundle.mjs (and its source map and assets)zfb dev and zfb build.
bundle-runtime.mjszfb build with a deploy adapter configured (the bundle the adapter receives).
dev-pages/, dev-assets/zfb dev.
graph.binzfb dev (the persisted dependency graph; the default layout keeps it at .zfb/graph.bin instead).
watcher-liveness-probe/zfb dev.
plugins/Plugins, through ctx.scratchDir. zfb does not create it. See Plugins.

zfb check and zfb preview write nothing of their own into a scratch dir. They only validate and lock it. Plugins that run under zfb preview still receive ctx.scratchDir and may write there.

zfb dev ignores the prebuilt output directory

Under a scratch dir, zfb dev never reads the output directory. A previous zfb build may have used different defines or a different state, so dev does not trust it:

  • no prebuilt HTML is served before a route's first render;

  • no /assets/* fallback to the output directory;

  • no boot-lazy seed, so ZFB_DEV_BOOT_LAZY=1 falls back to the eager startup render.

Dev logs one info line saying so:

scratch dir in use (<scratch>): ignoring prebuilt <outDir> — no dist/ fallback for HTML or /assets

Without a scratch dir, dev behaves exactly as before.

What stays shared on purpose

A scratch dir isolates zfb's own generated artifacts. These stay shared between concurrent commands by design:

  • The output directory. Two zfb build runs with the same output directory still overwrite each other. Pass a different --outdir to each one.

  • Temp files that are already per-process. esbuild entry, tsconfig, and virtual-module temp files in the project root, shadow sessions, and config-loader temp dirs each get a unique name per process, so they do not collide.

  • A user tsconfig's .tsbuildinfo. When your tsconfig turns on incremental or composite, zfb check runs tsc with your settings unchanged, so tsc writes that file where your tsconfig says.

  • The .zfb/ staging directory. Project code imports from it, so it cannot move.

  • Plugin-owned directories. A plugin that writes to its own project-root directory (for example .zudo-doc/) is still shared between concurrent commands. Plugin isolation requires the plugin to adopt ctx.scratchDir. See Plugins.

Per-invocation defines

--define KEY=EXPR adds an esbuild define substitution for one invocation, on top of the config's bundle.define. Use it to build or serve the same checkout with different values, for example a different origin per environment, without editing zfb.config.ts.

zfb build --define __ORIGIN__='"https://www.example.com"'
zfb dev --scratch-dir .zfb-build/dev-preview \
  --define __ORIGIN__='"https://preview.example.com"' \
  --define __FEATURE_X__=true
  • Commands. --define is repeatable and accepted by zfb dev, zfb build, and zfb check. zfb preview rejects it, because preview serves already-built output and bundles nothing.

  • Values are raw esbuild expressions, exactly like bundle.define. A string needs its own inner quotes: --define __ORIGIN__='"https://a.example"'. The shell's single quotes keep the inner double quotes intact. The flag splits on the first =, so the expression itself may contain =.

  • Merge. The CLI values are merged per key over bundle.define, and the CLI wins. When the same key is repeated, the last occurrence wins, so a script can append an override to a shared argument list. Plugins see the merged map in ctx.config.bundle.define.

  • Errors. A value with no =, an empty key, or an empty expression is a CLI usage error. So is a reserved key: import.meta.env.PROD, import.meta.env.DEV, or process.env.NODE_ENV. zfb does not check key syntax any further; esbuild reports an invalid key at bundle time, the same as for bundle.define.

  • Security. The warning on bundle.define applies here too: every value ends up in browser bundles, so never pass secrets.

--define with zfb check

zfb check validates and merges --define values, and then does not use them. They never reach tsc, which has no concept of defines. Declare the global's type in a .d.ts file that your tsconfig includes:

// components/zfb-env.d.ts
declare const __ORIGIN__: string;

Accepting the flag on check lets one argument list serve dev, build, and check, and a malformed value fails check instead of passing it and failing the build later.

Defines are part of the build identity

The effective define map is part of the build identity that zfb stamps into server-rendered island markup and into the island bundles. SSR output and islands built with different defines never hydrate each other: an island whose identity does not match fails with a ZR_IDENTITY error instead of hydrating silently with the wrong values.

The identity also includes project source files, excluding files matched by the project's own .gitignore files, hidden directories, and build or scratch output. zfb still includes its known first-party staging paths, such as .zudo-doc/routes-src, even when they are hidden or ignored. Linked local packages, plugin aliases, tsconfig paths targets, virtual modules, and the effective define map also feed the identity. For a linked package, zfb applies its .gitignore rules and the ancestor rules up to the workspace root, or up to the nearest Git root when the package is outside the workspace; if neither exists, the package itself is the boundary. Hidden entries are pruned from the walks. Declared package entry files and directories from exports, main, module, and imports (for example, dist/) remain included even when ignored; a declared entry file also brings in its relative-import closure. Concrete alias and tsconfig target files and their relative-import closures remain included even when ignored or hidden. An ignored file in a linked package that is neither declared nor reached through one of these closures is outside the identity. This is a file-selection policy, not a calculation of the exact reachable module graph. A gitignored project file imported directly by your code is outside the identity; remove such generated inputs from .gitignore and track them, or regenerate them deterministically.

Neither the checkout path nor the scratch dir location affects the identity or the emitted bundles. With the same commit, lockfile, and build options, local and CI builds produce byte-identical dist/ output. Ignored tool output such as test-results/ outside declared entries and target closures does not change the identity or bundle bytes. Without an applicable ignore rule, that output remains an identity input. In zfb dev, a write to an ignored file can still trigger a watcher tick, but it does not change the identity; watcher filtering is tracked in #3926.

Tip

When a dev server uses a different --define than your last zfb build, give it a --scratch-dir too. Under a scratch dir, dev ignores the prebuilt output directory, so it never serves pages built with the other define. The scratch dir also keeps its bundle apart from any concurrent build.

Environment variables

The following environment variables affect zfb globally (not specific to one subcommand):

VariableDefaultDescription
ZFB_DEV_EAGERunsetSet to 1 to disable lazy dev rendering in zfb dev. Re-renders every affected route eagerly on each file change.
ZFB_LAZY_DEV_RENDERunsetSet to 0 or 1 for precise control over the lazy dev-render switch. Takes precedence over ZFB_DEV_EAGER when both are set.
ZFB_DEV_BOOT_LAZYcoldDefault seedless lazy startup renders each route on request, so eager rendering of unrelated pages does not block edits after ready. Explicit 0/false restores eager startup; 1 retains legacy seed-based mode. Requires lazy rendering.
ZFB_DEV_DEFER_BUNDLEunsetSet to 0 to opt out of the boot-lazy bundle deferral (either variant; build the renderer before bind instead of after the first request). On by default when boot-lazy is active.
ZFB_DEV_TIMINGunsetSet to 1 or true to print dev-mode phase timings for each file-change tick.
ZFB_PLUGIN_HOOK_TIMEOUT120 (seconds)Maximum time (in seconds) any single plugin hook reply is awaited before the plugin host is force-killed and the build fails. Precedence: pluginHookTimeoutSecs in config > this env var > 120s built-in default.
ZFB_BUNDLERrolldownBundler for the page/SSR and islands bundles. rolldown (the default) bundles in process; esbuild uses the embedded esbuild instead. Any other value is a usage error. When it is unset, ZFB_ROLLDOWN_PROTOTYPE=0 still selects esbuild.
ZFB_ESBUILD_BINunsetAbsolute path to an esbuild binary. Used by the bundler, islands compiler, tests, and TypeScript config loading (zfb.config.ts) when an embedded/staged esbuild binary is unavailable or should be overridden.
ZFB_DEBUG_SNAPSHOTunsetSet to 1 or true to log the content snapshot's entry count and serialized byte size to stderr during zfb build, for monitoring V8 memory pressure on large sites. See Incremental Rebuild.
ZFB_SKIP_WRANGLER_VERSION_CHECKunsetSet to 1, true, or yes to bypass the zfb preview adapter-mode wrangler minimum-version gate. Intended for temporary release-engineering use.
ZFB_SCRATCH_DIRunsetScratch dir for zfb dev, zfb build, zfb check, and zfb preview when --scratch-dir is not passed. An empty or whitespace-only value counts as unset. See Scratch dirs.

Note

ZFB_PLUGIN_HOOK_TIMEOUT can also be set via pluginHookTimeoutSecs in defineConfig. The config field takes precedence over the environment variable.

Revision History

CreatedUpdated