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:
includeruns 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.excluderuns second, against entries that already passedinclude— a match drops the entry.
export default {
collections: [
{
name: "blog",
path: "content/blog",
include: ["**/*.mdx"],
exclude: ["**/*.draft.mdx"],
},
],
};This mirrors Astro's ['**/ 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.and pass element-level overrides through theContent components= {. . . } / > componentsprop. This is the same contract Astro's@astrojs/mdxexposes; see MDX Components for details anddefaultComponentsrecipes.slug— derived from the file name (my-first-post.md→my-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/ 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.allowOutsideRootwidens the..-escape check alone. An absolutepath(e.g./or a Windows drive-relative form likeetc/ passwd 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
collectionsentry contributed by a preset runs through the samevalidatestep as one you write yourself. If a preset you install declares a collection withallowOutsideRoot: 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 devresolves an out-of-root collection's root to a canonical absolute path and watches it through the same absolute watch channel used forextraWatchPaths(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: specifier; TSX entries use their source module directly (no separate compile step) and get a tsx: 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.