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() is a synchronous export. It returns an array of
{ params, props } objects. 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[];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, opaque to the engine, and forwarded verbatim to the page component as thepropsprop. The engine never inspects it.
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, props }) {
const post = getCollection("blog").find((e) => e.slug === params.slug);
if (!post) return <p>Not found.</p>;
return (
<article>
<h2>{props.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.paths()doesn't needasync, and neither does the page component.params.slugis what hits the URL. A post withslug: "hello-zfb"becomes/.blog/ hello- zfb props.titleis opaque to the engine — it's just data threaded to the component. You can 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()is synchronous. The whole content snapshot is loaded before any page evaluates, so there's no async boundary to wait on. Keep it pure-data and deterministic — the router calls it during route enumeration, well before render.paths()is evaluated once per route per build — the result is 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 synchronousgetCollectionAPI.