Routing Conventions
How paths() enumerates routes in zfb — synchronous, collection-backed, and SSG-safe.
In zfb, every dynamic or catchall page exports a paths() function that tells the build which concrete URLs to emit. This page explains how paths() works, what to put inside it, and what to avoid.
paths() is synchronous
paths() runs inside miniflare at build time and is synchronous by contract. The function signature is function paths(): PathEntry[] — not async.
// pages/docs/[[...slug]].tsx
import { getCollection, getEntry } from "zfb/content";
export function paths() {
const entries = getCollection("docs");
return entries.map((entry) => ({
params: { slug: entry.slug.split("/") },
}));
}
export default function DocsPage({ params }: { params: { slug: string[] } }) {
const slugPath = params.slug.join("/");
const entry = getEntry("docs", slugPath);
if (!entry) return <p>Not found.</p>;
return <entry.Content />;
}Use getCollection and getEntry for data access
Inside paths(), load content with getCollection(name) and getEntry(name, slug) from zfb/content. Both are synchronous — no await is needed or allowed.
import { getCollection } from "zfb/content";
export function paths() {
const docs = getCollection("docs"); // synchronous — no await
return docs
.filter((entry) => !entry.data.draft)
.map((entry) => ({
params: { slug: entry.slug.split("/") },
props: { title: entry.data.title },
}));
}These helpers read from the ContentSnapshot — an in-memory snapshot of every content collection built in Rust before any TypeScript module runs. By the time paths() executes, all data is already in memory; no I/O occurs at call time.
Why synchronous?
The ContentSnapshot is serialized to JSON and embedded on globalThis.__zfbat runtime startup, before the first TSX module evaluates.getCollection and getEntry read directly from that in-memory map — there is no async boundary to cross, and no await to thread through callers. See ADR-004for the full Rust ↔ JS bridge contract.
Do not fetch live data inside paths()
paths() can technically be written as async, but doing so breaks SSG guarantees.
// ❌ Anti-pattern — live fetch inside paths()
export async function paths() {
const res = await fetch("https://api.example.com/items");
const items = await res.json();
return items.map((item) => ({ params: { slug: item.id } }));
}This pattern causes several problems:
Non-determinism — the remote API may return different data on each build run
Reproducibility — the same commit can produce different HTML output
Offline breakage — builds fail in air-gapped environments or CI with no network access
Build latency — every build pays for a network round-trip during route enumeration
The content layer exists precisely to avoid this: fetch once, snapshot at build time, query synchronously from that snapshot.
Collection-backed paths: a minimal example
The snippet below is a minimal example of the pattern — a hand-rolled paths() built directly from getCollection/getEntry. It is not what zudo-doc's own docs catchall route does; see Package route injection and shadowing below for the real implementation. Each entry's slug maps to a string[] of URL segments:
// pages/docs/[[...slug]].tsx
import { getCollection, getEntry } from "zfb/content";
export function paths() {
const docs = getCollection("docs");
return docs
.filter((entry) => !entry.data.draft)
.map((entry) => ({
params: { slug: entry.slug.split("/") },
}));
}
export default function DocsPage({ params }: { params: { slug: string[] } }) {
const slugPath = params.slug.join("/");
const entry = getEntry("docs", slugPath);
if (!entry) return <p>Not found.</p>;
return <entry.Content />;
}/ matches with params.slug === ["guides", "configuration"]. Rejoin with slug. to reconstruct the lookup key for getEntry.
Package route injection and shadowing
The real pages/docs/[[...slug]].tsx doc-route stub does not hand-roll getCollection/getEntry like the minimal example above. It reconstructs the route via routeCtx.buildDocRouteEntries() and createChrome(), and a generated project normally doesn't author this file at all — see below.
With packageOwnedRoutes (the default) set to true, the @takazudo/zudo-doc routes plugin injects the documentation routes at build time. That includes /, its locale and version variants, and the tag and version routes. A generated project therefore ships almost no pages/ files of its own.
A project route still has priority over an injected route at the same path. When that happens, the build log reports:
shadowed by a user pages/ route (user wins); skippingThe user file is used and the package route is not injected. Use this intentionally when a route needs a project-specific implementation.
The / route is different. zfb does not permit injecting /, so the scaffold's pages/ is a one-line re-export of @takazudo/. It provides the home page; it is not an example of route shadowing.
The self-contained documentation-route stubs in the scaffold are also not optional shadowing examples. Injected dynamic routes currently return 404 in zfb dev, so the stubs are required to keep pnpm dev working for doc pages. This is a separate upstream limitation from the / injection restriction.
When you customize a doc-route stub that shadows an injected route, it must also import chromeBindings from virtual:zudo-doc-chrome-bindings and pass it to createChrome. Otherwise the chromeBindingsModule channel is severed for your doc pages. See Custom Components for the complete stub-threading patterns.
Migration from Astro's getStaticPaths
If you're migrating a project from Astro, paths() replaces getStaticPaths(). The main difference is that paths() is synchronous and all data must be pre-loaded into content collections before the build runs.
Astro's getStaticPaths() accepted any async operation. Patterns like the following were common:
// ❌ Astro — before migration
// pages/docs/tags/[tag].tsx
export async function getStaticPaths() {
const res = await fetch("https://api.example.com/tags");
const tags = await res.json();
return tags.map((tag: { slug: string }) => ({
params: { tag: tag.slug },
}));
}In zfb this becomes a two-step process.
Step 1 — Move the fetch into a build-time generator script. Run it before zfb build and write its output into a content collection or a static JSON file:
// scripts/fetch-tags.ts — run as a pre-build step
import { writeFileSync, mkdirSync } from "fs";
const res = await fetch("https://api.example.com/tags");
const tags = await res.json();
mkdirSync("src/content/tags", { recursive: true });
for (const tag of tags) {
writeFileSync(
`src/content/tags/${tag.slug}.json`,
JSON.stringify(tag),
);
}Step 2 — Read the pre-fetched data inside paths() using getCollection:
// ✅ zfb — after migration
// pages/tags/[tag].tsx
import { getCollection, getEntry } from "zfb/content";
export function paths() {
const tags = getCollection("tags"); // reads the pre-generated content
return tags.map((tag) => ({
params: { tag: tag.slug },
props: { label: tag.data.label },
}));
}
export default function TagPage({
params,
props,
}: {
params: { tag: string };
props: { label: string };
}) {
return <h1>{props.label}</h1>;
}For data that never changes between deploys (e.g. an initial dataset committed to the repo), you can skip the generator script entirely and author the JSON files directly in src/content/.
Quick comparison
Astro getStaticPaths | zfb paths | |
|---|---|---|
| Signature | async function getStaticPaths() | function paths() |
| Runtime | Node.js | miniflare |
| Data access | await getCollection(...) or await fetch(...) | getCollection(...) / getEntry(...) (sync) |
| Live HTTP fetch | Allowed (not recommended for SSG) | Move to a pre-build step |
| Return shape | { params, props }[] | { params, props }[] |
| Draft filtering | Filter in getStaticPaths | Filter in paths() |
See also
i18n — per-locale route enumeration with
paths()Versioning — versioned docs and their route conventions