zfb
GitHub repository

Type to search...

to open search from anywhere

Content Collections

Define typed collections of Markdown content in zfb.config and load them from pages.

A content collection is a directory of .md, .mdx, or .tsx entries declared in your project config. zfb scans the directory at build time, parses each entry's frontmatter against a schema you supply, and exposes the entries through getCollection() and getEntry() helpers your pages can call.

Declaring a collection

Collections are configured in zfb.config.ts (or zfb.config.json) under the collections key. Each entry has a name and a path:

export default {
  collections: [
    {
      name: "blog",
      path: "content/blog",
    },
  ],
};

The name is the identifier you pass to getCollection() or getEntry(). The path is the directory (relative to the project root) holding the entries. zfb walks that directory, accepts .md, .mdx, and .tsx files, and exposes the resulting entries through getCollection("blog").

Markdown and MDX entries parse YAML frontmatter and compile the body through the MDX pipeline. TSX entries use a literal export const frontmatter = { ... } and carry the TSX source as the entry module; their body field is empty because there is no separate Markdown body.

You can additionally supply an optional schema field — a JSON Schema subset that validates each entry's frontmatter when you run zfb check (the build itself does not enforce it). The supported keywords (type, properties, items, required, enum) are documented on the defineConfig page. The [{ name, path }] form remains supported for projects that don't need per-field validation.

Filtering and slug rewriting

Two more fields narrow which files count as entries and normalize slugs across locale variants.

include / exclude

Both take an array of globs in the globset dialect (Unix-style: *, **, ?, [...]), matched against each candidate file's path relative to the collection's path (not the project root). When set:

  • include runs first — an entry is kept only if at least one pattern matches. Omit it (or leave it empty) to skip this stage entirely; every file then passes.

  • exclude runs second, against entries that already passed include — a match drops the entry.

export default {
  collections: [
    {
      name: "blog",
      path: "content/blog",
      include: ["**/*.mdx"],
      exclude: ["**/*.draft.mdx"],
    },
  ],
};

This mirrors Astro's ['**/*.mdx', '!**/*.draft.mdx'] convention — zfb splits the negative side into its own exclude field instead of prefixing a pattern with !.

idStripSuffix

When an entry's derived slug ends with this fixed string, the suffix is stripped from both the entry's slug and its module_specifier; entries that don't end with the suffix pass through unchanged. The main use case is a multi-locale layout where one directory holds both the default-locale file and a locale-suffixed override (foo.mdx and foo.en.mdx): declare a second collection over the same path with idStripSuffix: ".en", and that collection's slugs round-trip as foo instead of foo.en. Because the strip is applied consistently to the specifier as well as the slug, getEntry("blog-en", "foo") still resolves correctly even though the underlying file is foo.en.mdx.

Loading entries from a page

Pages use getCollection() to enumerate every entry:

import { getCollection } from "zfb/content";

export default function BlogIndex() {
  const posts = getCollection("blog");
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <a href={`/blog/${post.slug}`}>{post.data.title}</a>
        </li>
      ))}
    </ul>
  );
}

Use getEntry(name, slug) when you already know the slug:

import { getEntry } from "zfb/content";

export default function FeaturedPost() {
  const featured = getEntry("blog", "hello-zfb");
  if (!featured) return null;
  return <featured.Content />;
}

getCollection() and getEntry() are synchronous. The entire content snapshot is built in Rust before any TSX module runs and embedded on globalThis.__zfb, so there's no I/O at call time and no await to thread through. The Rust↔JS bridge contract that backs this surface is stable and versioned with the zfb package.

Each entry has three things you can rely on:

  • data — the parsed, validated frontmatter (typed against your schema).

  • Content — a renderable React/Preact component compiled from the body. Render it as <post.Content components={...} /> and pass element-level overrides through the components prop. This is the same contract Astro's @astrojs/mdx exposes; see MDX Components for details and defaultComponents recipes.

  • slug — derived from the file name (my-first-post.mdmy-first-post). Nested directories become slash-separated slugs.

The function signature is documented at getCollection.

Collections outside the project root

By default, a collection's path must resolve inside the project root — a .. segment that would escape it is rejected at config-load time. Set allowOutsideRoot: true on a collection to opt into a path that walks outside the root, which is useful in a monorepo where content is co-located with a sibling package rather than duplicated into the docs project:

export default {
  collections: [
    {
      name: "componentDocs",
      path: "../packages/ui/src",
      include: ["**/*.mdx"],
      allowOutsideRoot: true,
    },
  ],
};

This walks up from the project root into a sibling packages/ui/src directory and collects every .mdx file under it, letting component documentation live next to the component source it describes instead of being copied into the docs project.

Keep these in mind before enabling the flag:

  • Only ..-relative escapes are relaxed. allowOutsideRoot widens the ..-escape check alone. An absolute path (e.g. /etc/passwd or a Windows drive-relative form like C:temp) is still rejected even with the flag set — there is no way to opt into an absolute collection path.

  • A preset that sets this flag widens your read surface. Presets are merged into your config before validation, so a collections entry contributed by a preset runs through the same validate step as one you write yourself. If a preset you install declares a collection with allowOutsideRoot: true, you are trusting that preset to read anywhere on the filesystem its ..-relative path can reach — review any preset-contributed collection the same way you'd review a preset-contributed plugin.

  • Out-of-root directories are watched too. zfb dev resolves an out-of-root collection's root to a canonical absolute path and watches it through the same absolute watch channel used for extraWatchPaths (see Watching paths outside the project root). Edits under the out-of-root directory trigger live reload exactly like edits to an in-root collection.

How parsing works

Under the hood, the zfb-content crate handles three jobs: it walks the configured directory, parses each file's frontmatter, and compiles the entry to JSX source that is then handed to the existing SWC TSX -> JS pipeline. Markdown/MDX entries use the MDX emitter and get an mdx://<collection>/<slug>#<hash> specifier; TSX entries use their source module directly (no separate compile step) and get a tsx://<collection>/<slug>#<hash> specifier. In both cases the trailing hash is derived from the compiled JSX source. The page renderer evaluates that module on demand and surfaces it to your page as entry.Content.

The compilation and surface contract are stable. See MDX Components for the rendering side.

Revision History

CreatedUpdated