zfb
GitHub repository

Type to search...

to open search from anywhere

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[]>;
  • params keys must match the bracketed names in the filename. For [slug].tsx, the key is slug. For [lang]/[slug].tsx, you supply both lang and slug. For catchall [...slug].tsx, slug is a string[] of the trailing segments.

  • props is optional and opaque to the engine — it's just data you control. It is not passed to the component as a nested props object: each key is spread onto the component's props at the top level, alongside params. A paths() entry returning props: { title: "..." } means the component receives { params, title }, not { params, props: { title } }. If a props key collides with params, the props value 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:

  • getCollection is synchronous — the full content snapshot is pre-built in Rust before any TSX runs — so this example does not need async. Use export async function paths() when your route enumeration needs async module loading or another Promise-backed helper.

  • params.slug is what hits the URL. A post with slug: "hello-zfb" becomes /blog/hello-zfb.

  • title — like any key you put in props — is opaque to the engine. It arrives spread onto the component's top-level props alongside params (see above), not nested under a props key. 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 />;
}

/docs/concepts/routing matches with params.slug === ["concepts", "routing"]. /docs/guides/setup matches with params.slug === ["guides", "setup"]. The router rebuilds the slash-separated form (slug.join("/")) when you need to look up an entry by it.

Static, dynamic, and catchall — how they fit together

FilenameKindExample URLparams shape
pages/about.tsxstatic/aboutn/a
pages/blog/[slug].tsxdynamic/blog/hello-zfb{ slug: string }
pages/docs/[...slug].tsxcatchall/docs/a/b/c{ slug: string[] }
pages/docs/[[...slug]].tsxoptional catchall/docs and /docs/a/b/c{ slug: string[] } ([] for the bare URL)
pages/[lang]/[slug].tsxdynamic × 2/ja/intro{ 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 params key must have a value. Missing keys raise a build error before any HTML is written.

  • For catchall segments, params.slug must be a string[] (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 (/docs), rename the file to the optional form ([[...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-returning paths() 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 paths result can be read without running the module; a runtime paths() result is evaluated once, then memoised and reused for every page rendered from the same route template. Per-entry work inside paths() (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::AmbiguousRoute at build time. The router also raises RouterError::AmbiguousShape when two routes differ only in parameter names but match the same URLs (e.g. docs/[a].tsx vs docs/[b].tsx), and RouterError::OptionalCatchallConflict when 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 synchronous getCollection / getEntry API.

  • Static route propspaths() 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.

Revision History

CreatedUpdated