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`.

```tsx
// 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.

```ts
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.

[Dynamic content unavailable in text export]

The ContentSnapshot is serialized to JSON and embedded on `globalThis.__zfb`
at 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-004](https://takazudomodular.com/pj/zfb/architecture/adr-004-content-bridge)
for 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.

```tsx
// ❌ 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](https://zudo-doc.takazudomodular.com/docs/develop/routing-conventions#package-route-injection-and-shadowing)
below for the real implementation. Each entry's slug maps to a `string[]`
of URL segments:

```tsx
// 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:

```text
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 retained as
explicit host-owned seams, not because injected dynamic routes fail in dev.
zfb 2.13.1 renders those injected routes in both dev and build. The `/` route
remains a separate exception because zfb does not permit injecting `/`.

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](https://zudo-doc.takazudomodular.com/docs/guides/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:

```ts
// ❌ 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:

```ts
// 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`:

```tsx
// ✅ 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](https://zudo-doc.takazudomodular.com/docs/guides/i18n) — per-locale route enumeration with `paths()`
- [Versioning](https://zudo-doc.takazudomodular.com/docs/guides/versioning) — versioned docs and their route conventions