zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Routing Conventions

Created Apr 28, 2026Updated Jul 24, 2026Takeshi Takatsudo

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 />;
}

/docs/guides/configuration matches with params.slug === ["guides", "configuration"]. Rejoin with slug.join("/") 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 /docs/[[...slug]], 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); skipping

The 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/index.tsx is a one-line re-export of @takazudo/zudo-doc/routes/index. 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 getStaticPathszfb paths
Signatureasync function getStaticPaths()function paths()
RuntimeNode.jsminiflare
Data accessawait getCollection(...) or await fetch(...)getCollection(...) / getEntry(...) (sync)
Live HTTP fetchAllowed (not recommended for SSG)Move to a pre-build step
Return shape{ params, props }[]{ params, props }[]
Draft filteringFilter in getStaticPathsFilter in paths()

See also

  • i18n — per-locale route enumeration with paths()

  • Versioning — versioned docs and their route conventions

Revision History

Takeshi TakatsudoCreated: 2026-04-28T20:38:07+09:00Updated: 2026-07-25T02:50:35+09:00

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…