Dynamic Routes
Use paths() to enumerate the concrete URLs a [slug].tsx or [...slug].tsx page should build, and pass per-URL props to the component.
What this page covers
How paths() enumerates the URLs a dynamic or catchall page should emit, the { params, props } contract it returns, and how page components receive the data. For static-route fundamentals, seeRouting.
A dynamic route — pages/blog/[slug].tsx — doesn't map to a single URL. The bracketed segment is a parameter, and zfb needs to know which concrete values to fill it with at build time. That's the job of paths().
Catchall routes — pages/docs/[...slug].tsx — work the same way, but their slug parameter captures one or more trailing segments instead of a single one.
The paths() contract
paths() returns an array of { params, props } objects, either synchronously or as a Promise. The router consumes the array; one entry becomes one rendered URL.
type PathEntry<P = Record<string, unknown>> = {
/** Values for the bracketed segments, keyed by parameter name. */
params: Record<string, string | string[]>;
/** Optional per-URL data threaded to the page component as `props`. */
props?: P;
};
export function paths(): PathEntry[] | Promise<PathEntry[]>;paramskeys must match the bracketed names in the filename. For[slug].tsx, the key isslug. For[lang]/[slug].tsx, you supply bothlangandslug. For catchall[...slug].tsx,slugis astring[]of the trailing segments.propsis optional and opaque to the engine — it's just data you control. It is not passed to the component as a nestedpropsobject: each key is spread onto the component's props at the top level, alongsideparams. Apaths()entry returningprops: { title: "..." }means the component receives{ params, title }, not{ params, props: { title } }. If apropskey collides withparams, thepropsvalue wins.
Source and compiled entrypoints
A dynamic route needs an ESM paths export whether its entrypoint is a project page or a compiled .js module registered with injectRoute. Compiled output often keeps the local binding and exports it with a clause:
function paths() {
return [{ params: { slug: "intro" } }];
}
export { paths };zfb recognizes this local export-clause form. When its result is statically literal, zfb can enumerate it directly. When paths() depends on imports, collections, async work, or other nonliteral code, zfb evaluates the runtime export during route enumeration. A package can therefore inject its built dist/ route directly; it does not need a parallel source .tsx entrypoint. This does not change file-system discovery: a .js file placed under pages/ is not scanned as a user page.
A blog post page
The canonical use of paths() is enumerating slugs from a content collection:
// pages/blog/[slug].tsx
import { getCollection } from "zfb/content";
export const frontmatter = { title: "Blog post" };
export function paths() {
const posts = getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: { title: post.data.title },
}));
}
export default function BlogPost({ params, title }) {
const post = getCollection("blog").find((e) => e.slug === params.slug);
if (!post) return <p>Not found.</p>;
return (
<article>
<h2>{title}</h2>
<post.Content />
</article>
);
}A few things worth noting:
getCollectionis synchronous — the full content snapshot is pre-built in Rust before any TSX runs — so this example does not needasync. Useexport async function paths()when your route enumeration needs async module loading or another Promise-backed helper.params.slugis what hits the URL. A post withslug: "hello-zfb"becomes/.blog/ hello- zfb title— like any key you put inprops— is opaque to the engine. It arrives spread onto the component's top-level props alongsideparams(see above), not nested under apropskey. Put anything serializable there.
Catchall: a full-tree docs page
Catchall routes capture any number of trailing segments, useful when the same template renders many depths under one prefix. The slug parameter arrives as string[]:
// pages/docs/[...slug].tsx
import { getCollection } from "zfb/content";
export const frontmatter = { title: "Docs" };
export function paths() {
const entries = getCollection("docs");
return entries.map((entry) => ({
// entry.slug looks like "guides/setup" or "concepts/routing"
params: { slug: entry.slug.split("/") },
}));
}
export default function DocsPage({ params }) {
const slugPath = params.slug.join("/");
const entry = getCollection("docs").find((e) => e.slug === slugPath);
if (!entry) return <p>Not found.</p>;
return <entry.Content />;
}/ matches with params.slug === ["concepts", "routing"]. / matches with params.slug === ["guides", "setup"]. The router rebuilds the slash-separated form (slug.) when you need to look up an entry by it.
Static, dynamic, and catchall — how they fit together
| Filename | Kind | Example URL | params shape |
|---|---|---|---|
pages/ | static | / | n/a |
pages/blog/[slug].tsx | dynamic | / | { slug: string } |
pages/docs/[...slug].tsx | catchall | / | { slug: string[] } |
pages/docs/[[...slug]].tsx | optional catchall | / and / | { slug: string[] } ([] for the bare URL) |
pages/[lang]/[slug].tsx | dynamic × 2 | / | { lang: string, slug: string } |
When two patterns can match the same URL, the more specific one wins: static beats dynamic; dynamic beats catchall. The router enforces this when it builds the route table — see Routing for the full sort.
Rules and gotchas
Every
paramskey must have a value. Missing keys raise a build error before any HTML is written.For catchall segments,
params.slugmust be astring[](even with one element). Passing a plain string is a type error.A required catchall (
[...slug]) rejects the empty array — it always needs at least one segment. To build the bare directory URL (/), rename the file to the optional form (docs [[...slug]]) and return an explicit{ params: { slug: [] } }entry.[""]and""stay invalid for both forms.paths()may be synchronous or async. The whole content snapshot is loaded before any page evaluates, so content-only routes can usually stay synchronous; Promise-returningpaths()exports are also supported. Keep the result pure-data and deterministic — the router calls it during route enumeration, well before render.Route enumeration happens once per route per build. A statically literal
pathsresult can be read without running the module; a runtimepaths()result is evaluated once, then memoised and reused for every page rendered from the same route template. Per-entry work insidepaths()(such as mapping a collection) is safe and will not be repeated for each output URL.Two routes that resolve to the same template raise
RouterError::AmbiguousRouteat build time. The router also raisesRouterError::AmbiguousShapewhen two routes differ only in parameter names but match the same URLs (e.g.docs/[a].tsxvsdocs/[b].tsx), andRouterError::OptionalCatchallConflictwhen an optional catchall overlaps another route at the same position. The router never silently picks a winner.
See also
Routing — static-route fundamentals.
Content Collections — the data source most
paths()calls draw from; also documents the synchronousgetCollection/getEntryAPI.Static route props —
paths()enumerates URLs for dynamic and catchall routes;getStaticProps()is the equivalent for a single static route that needs build-time data without a bracketed segment.