Plugins
Author and consume zfb plugins — the four lifecycle hooks, virtual modules, import aliases, and dev-only injected routes.
zfb plugins are plain ES modules whose default export is a ZfbPlugin object. The zfb build (and dev) host loads each plugin module once at boot and dispatches four optional lifecycle hooks to it. A plugin may declare any subset of the four — anything it omits is a silent no-op.
This page documents the contract for plugin authors. The companion API reference is on <code>defineConfig.
When you actually need a plugin
Reach for a plugin when you need one of the four capabilities that setup provides — the same four the sections below document. If none of these apply, a plain script is the right tool instead (see When you don’t — write a recipe below). For the broader engine-vs-script mental model, see Design Philosophy.
- Virtual module backing a synthetic data source. If your pages need to
importfrom a specifier that has no real file on disk — a metadata DB, a content index, a generated config blob —addVirtualModuleis the only way to inject that source into the module graph. Example:import metadata from "virtual:metadata-db"across every page. - Alias rewrite that must apply across all bundlers.
addAliasregisters an exact-match import rewrite that is honored by all three consumers: the embedded V8 host, the main page/layout bundler, and the islands esbuild bundler. Atsconfig.jsonpathsentry only reaches the type-checker. If the alias has to work at runtime in all three bundlers, it belongs in a plugin. - Dev-only injected route that lives outside
pages/.injectRouteregisters a TSX/TS file under a URL pattern the dev server recognises — a synthetic page that has no counterpart in the on-diskpages/tree, gated oncommand === "dev"so it never escapes into a production build. - Dev middleware HTTP handler.
devMiddlewareis for responses that aren’t pages at all: a JSON API endpoint, a hot-reload bridge, an upload handler. No JSX pipeline, no page renderer — just a function returning{ status, headers, body }.
The four hooks
import { definePlugin } from "@takazudo/zfb/plugins";
export default definePlugin({
name: "my-plugin",
setup?(ctx) {}, // #255 — runs once at host boot, before preBuild
preBuild?(ctx) {}, // file-generation work before the bundler / renderer
postBuild?(ctx) {}, // finalisation after dist/ has been written
devMiddleware?(ctx) {},// per-request HTTP handlers in `zfb dev`
});
Hooks run sequentially in the order plugins appear in zfb.config.ts’s plugins array. A throw in any hook aborts the build (or the dev boot) and surfaces the plugin name + the hook that threw in the error banner.
setup — register virtual modules, aliases, and injected routes
Runs once per zfb build and once per zfb dev host boot, before preBuild. The hook is where a plugin contributes to the module-resolution pipeline (virtual modules + import aliases) and registers any dev-only synthetic routes. After setup completes, the registries are frozen for the remainder of the run.
setup({ command, projectRoot, config, options, logger, addAlias, addVirtualModule, injectRoute }) {
// `command` is "build" or "dev". Gate dev-only registrations on it.
addAlias("@/components/foo", "./src/components/foo.tsx");
addVirtualModule("virtual:my-data", () =>
`export default ${JSON.stringify(myJson)}`,
);
if (command === "dev") {
injectRoute("/api/dev/x", "./scripts/dev-x.ts");
}
}
addAlias(from, to) — exact-match import rewrites
Registers a single import specifier that, when matched exactly, resolves to to. The path is joined against the project root.
addAlias("@/components/foo", "./src/components/foo.tsx");
After this, import Foo from "@/components/foo" resolves to ..
Subpath imports do NOT match: import "@/ is not rewritten and surfaces as an unresolved-import error at bundle time. All three consumers (the embedded V8 host that drives SSR and paths() evaluation, the main page/layout bundler, and the islands esbuild bundler that produces client-side "use client" bundles) honor the same exact-match contract.
Conflict detection. Two plugins registering the same from with different to raises AliasConflict and aborts the build, naming both offending plugins. Idempotent re-registration (same plugin, same to) is allowed.
addVirtualModule(specifier, loader) — synthetic module sources
Registers a bare specifier whose source text is produced on-demand by loader. The recommended prefix is virtual: but it is not enforced — anything that does not collide with a real module specifier works.
addVirtualModule("virtual:metadata-db", () =>
`export default ${JSON.stringify(buildMetadataIndex())}`,
);
loader returns the complete ESM source text as a string. The bundler / embedded V8 host feeds the returned string in as the module’s source verbatim. The loader is invoked exactly once per build at the moment the specifier is first imported; subsequent imports of the same specifier reuse the cached source.
There is one loader contract. There is no alternate “loader returns JSON and zfb wraps it” mode — if you want to expose JSON, do () => "export default " + JSON.stringify(data) yourself.
Conflict detection. Two plugins registering the same specifier raises VirtualModuleConflict and aborts the build.
injectRoute(pattern, entrypoint) — dev-only synthetic page routes
Registers a TSX/TS file under a URL pattern the dev server is aware of, conceptually routed through the same page-rendering pipeline as pages/<...>.tsx. The pattern follows the pages/ filename grammar (/, /, /); entrypoint is resolved relative to the project root.
injectRoute("/api/dev/x", "./scripts/dev-x.ts");
⚠️ Renderer integration is a follow-up
The registry, conflict detection, dev-only guard, and pattern-matching plumbing are all shipping in v1. Full evaluation of the matched entrypoint through the page renderer is a follow-up issue. Today the dev server logs a structured match on the zfb_plugin tracing target when the URL matches an injected pattern, so you can confirm the registration landed end-to-end — but the request then falls through to the existing dist / public fallbacks (and 404s if no other file claims the URL). The renderer-side wiring will land without changing the public API documented here.
Dev-only. Calling injectRoute when command === "build" raises InjectRouteInBuildMode and aborts the build. Static builds should not surface SSR-shaped routes; if you need dev-only HTML on a real URL, gate the call with if (command === "dev").
Conflict detection. Two plugins registering the same pattern raises InjectRouteConflict and aborts the dev boot.
injectRoute vs devMiddleware — pick the right hook
Both add new URLs that only exist during zfb dev, but they aim at different problems:
| Hook | Returns | Use when |
|---|---|---|
injectRoute(pattern, entrypoint) | a TSX/TS module that the page renderer evaluates and rasterises to HTML | you want a real, JSX-shaped page on a dev-only URL (matching zmod’s Astro injectRoute). |
devMiddleware(ctx) → ctx.register(path, handler) | a JS function that returns { status, headers, body } per request | you want an HTTP handler — JSON API, a hot-reload bridge, an upload endpoint. No page pipeline, no JSX. |
devMiddleware is the right answer when the response is not a page; injectRoute is the right answer when you want a synthetic pages/ that lives outside the on-disk pages/ tree.
Closed surface — no markdown extension hooks
SetupContext exposes exactly three registration methods: addAlias, addVirtualModule, injectRoute. The deliberate omissions:
- no
addRemarkPlugin/addRehypePlugin/addMarkdownVisitor, - no
addModuleLoader/addModuleTransform, - no
onConfigResolved/onModuleLoad.
Markdown extensibility lives inside zfb’s tree as in-tree Rust visitors (TOC, external links, CJK handling, etc.); future markdown features are added to the engine, not exposed as JS plugin points. This keeps the v1 contract narrow and the build pipeline auditable.
preBuild(ctx) and postBuild(ctx)
preBuildruns aftersetupand before the bundler / renderer / CSS / islands work. Use it to emit files the downstream stages will see.postBuildruns afterdist/has been fully written (including any adapter wrapping). Use it for finalisation steps that need a complete tree on disk.
Both hooks receive { projectRoot, outDir, config, options, logger }. postBuild additionally receives ctx.routes — the complete route manifest for the build. See <code>ZfbBuildHookContext</code> for the full shape.
ctx.routes — the route manifest (postBuild only)
postBuild plugins receive a ctx.routes object describing every URL the build emitted. The field is absent (undefined) during preBuild — the manifest is not available until rendering finishes.
interface ZfbRouteManifest {
routes: ZfbRouteEntry[];
}
interface ZfbRouteEntry {
url: string; // emitted URL path, e.g. "/blog/hello/"
output: string; // path under outDir, e.g. "blog/hello/index.html"
extension: string; // file extension: "html", "xml", "rss", "txt", "json", …
source: string; // source page module, e.g. "pages/blog/[slug].tsx"
prerender: boolean; // true = SSG (written to disk under outDir);
// false = SSR (no on-disk artifact, served by the adapter)
params?: Record<string, string | string[]>; // absent for static routes;
// dynamic params are strings, catchall params are string[]
}
Routes are sorted by url for byte-stable output across runs. Non-HTML routes (sitemap.xml.tsx, feed.rss.tsx, llms.txt.tsx) appear with their actual extension and output path.
The manifest includes both SSG routes (prerender: true) and SSR routes (prerender: false). SSR routes are valid runtime URLs served by the adapter — but they have no on-disk artifact under outDir. Indexes that enumerate “URLs the build wrote to disk” (sitemap.xml, search-index.json, …) should filter r.prerender !== false to avoid surfacing those.
On-disk access — dist/ _ _ zfb/ routes. json
The same manifest is also written to <outDir>/ at the end of every zfb build (#347). The on-disk file mirrors the in-memory ctx.routes shape one-for-one — same fields, same url-sorted order — so any script wired into pnpm build (a sibling sitemap generator, an OGP indexer, a search-shard builder) can read the manifest without writing a zfb plugin.
{
"routes": [
{ "url": "/", "output": "index.html", "extension": "html",
"source": "pages/index.tsx", "prerender": true },
{ "url": "/blog/hello/", "output": "blog/hello/index.html",
"extension": "html", "source": "pages/blog/[slug].tsx",
"prerender": true, "params": { "slug": "hello" } }
]
}
The plugin ctx.routes and the on-disk routes.json are two access shapes over the same data, not two contracts. Opt out by setting emitRoutesManifest: false in zfb.config.ts for projects that strip everything but shipped assets out of dist/ before deploy.
Worked example: generating a sitemap.xml in postBuild
A plugin that writes a sitemap.xml from every HTML route the build produced. The filter combines extension === "html" (skip .xml / .rss / .txt routes) with prerender !== false (skip SSR routes that have no on-disk artifact).
// plugins/sitemap.ts
import { definePlugin } from "@takazudo/zfb/plugins";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
export default definePlugin({
name: "sitemap",
postBuild({ outDir, routes }) {
if (!routes) return; // guard: absent on preBuild
const siteUrl = "https://example.com";
const htmlRoutes = routes.routes.filter(
(r) => r.extension === "html" && r.prerender !== false,
);
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...htmlRoutes.map(
(r) => ` <url><loc>${siteUrl}${r.url}</loc></url>`,
),
"</urlset>",
].join("\n");
writeFileSync(join(outDir, "sitemap.xml"), xml, "utf-8");
},
});
// zfb.config.ts
import { defineConfig } from "@takazudo/zfb/config";
export default defineConfig({
plugins: [{ name: "./plugins/sitemap.ts" }],
});
The plugin runs after dist/ is fully written; any file it creates alongside the emitted HTML pages is served as a static asset in production.
📝 SSR route over-inclusion gap
The filter r.extension === "html" above will over-include server-rendered (SSR) routes — routes where prerender is false — once SSR ships. Today ctx.routes[].prerender does not exist on ZfbRouteEntry. When it does, the correct filter will be r.extension === "html" && r.prerender !== false. Do not use that expression yet; the field is not there. Tracked in zudo-front-builder#347.
devMiddleware(ctx)
Unchanged from v1. Register one or more HTTP handlers via ctx.register(path, handler); handlers return { status, headers, body } (or undefined to fall through to zfb’s built-in dev routes).
See <code>ZfbDevMiddlewareContext</code> for the shape.
Worked example: virtual:metadata-db
A plugin that builds a metadata index and exposes it as a virtual module. Pages can then import metadata from "virtual:metadata-db" without zfb knowing anything about the index format.
// plugins/metadata-db.ts
import { definePlugin } from "@takazudo/zfb/plugins";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
export default definePlugin({
name: "metadata-db",
setup({ projectRoot, addVirtualModule }) {
addVirtualModule("virtual:metadata-db", () => {
const dir = join(projectRoot, "src/content/docs");
const entries = readdirSync(dir, { recursive: true })
.filter((p) => typeof p === "string" && p.endsWith(".mdx"))
.map((relPath) => {
const body = readFileSync(join(dir, relPath as string), "utf-8");
// ... parse frontmatter, compute slug, etc.
return { slug: relPath, title: "...", description: "..." };
});
return `export default ${JSON.stringify(entries)}`;
});
},
});
// zfb.config.ts
import { defineConfig } from "@takazudo/zfb/config";
export default defineConfig({
plugins: [{ name: "./plugins/metadata-db.ts" }],
});
// pages/index.tsx
import metadata from "virtual:metadata-db";
export default function Home() {
return (
<ul>
{metadata.map((m) => (
<li key={m.slug}><a href={`/${m.slug}`}>{m.title}</a></li>
))}
</ul>
);
}
The loader runs once at the start of zfb build; the bundler caches the result and every import of virtual:metadata-db sees the same source. On the next zfb build the loader runs again — there is no on-disk cache between builds.
Conflict-detection summary
When two plugins clash on a registration, zfb aborts the build/dev with one of three errors that names both offending plugins:
AliasConflict— samefrom, differentto.VirtualModuleConflict— same specifier, different plugins.InjectRouteConflict— same URL pattern, different plugins.
InjectRouteInBuildMode is raised when any plugin calls injectRoute during zfb build (regardless of who registered first).
When you don’t — write a recipe
Not everything that runs at build time needs a plugin. If your task doesn’t require setup-level capabilities (no virtual module, no alias, no injected route, no dev middleware), a plain Node.js script in scripts/ wired into pnpm build is simpler, easier to test in isolation, and less coupled to zfb’s internals. The Design Philosophy and Engine vs Framework pages explain the broader principle.
Common candidates that do not need a plugin:
- Sitemap generation.
postBuildgives youctx.routes— but you can read the samedist/tree from a standalone script. No module graph access needed; wire it intopnpm build. - OGP image emission. Rendering open-graph images from page metadata is a pure data-in / image-out transform. A standalone script that reads built HTML or a JSON manifest and calls a canvas/puppeteer/satori pipeline needs no plugin hook; wire it into
pnpm build. - Search-index builds. Tools like Pagefind or Lunr crawl the finished
dist/tree. They need no access to zfb internals — just a path to the output directory; wire it intopnpm build. - Build-end manifests. If you need a custom JSON manifest (asset list, version map, route catalog) derived from the emitted files, a script that reads
dist/after the build is self-contained; wire it intopnpm build.
See also
- <code>defineConfig</code> — wiring
plugins: [{ name: "...", options: {...} }]. - Build Pipeline — where each hook lands in the overall sequence.