Content Layer
Astro-style build-time content collections with Zod validation and digest caching.
On this page
Content Layer
The Content Layer is Mandu's build-time content system — files and API data get parsed, validated, and cached into typed collections you can query from server components. Inspired by Astro's Content Layer.
Why
- Type-safe: Zod schema validation catches bad frontmatter at build time.
- Fast: Digest-based caching re-parses only changed files.
- Mixed sources: Markdown files, JSON, YAML, and external APIs — one API.
- Fits Mandu's render modes: Collections power prerender at build, SSR at runtime, and islands in between.
Defining collections
Create content.config.ts at the project root:
// content.config.ts
import { defineContentConfig, glob, file, api } from "@mandujs/core/content";
import { z } from "zod";
const postSchema = z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
});
export default defineContentConfig({
collections: {
// Markdown files with frontmatter
posts: {
loader: glob({ pattern: "content/posts/**/*.md" }),
schema: postSchema,
},
// Single JSON/YAML file
settings: {
loader: file({ path: "data/settings.json" }),
schema: z.object({ siteName: z.string(), theme: z.string() }),
},
// External API (cached)
products: {
loader: api({
url: "https://api.example.com/products",
headers: () => ({ Authorization: `Bearer ${process.env.API_KEY}` }),
cacheTTL: 3600,
}),
schema: z.array(z.object({ id: z.string(), price: z.number() })),
},
},
});
Loaders
| Loader | Purpose | Example |
|---|---|---|
glob() |
Many files, pattern-matched | glob({ pattern: "content/posts/**/*.md" }) |
file() |
Single config file | file({ path: "data/site.json" }) |
api() |
HTTP endpoint with caching | api({ url, cacheTTL: 3600 }) |
All loaders stream the content through the schema. Invalid entries stop the build — you never ship malformed data to production.
Querying
// app/blog/page.tsx (server component)
import { getCollection } from "@mandujs/core/content";
export default async function Blog() {
const posts = await getCollection("posts");
const published = posts
.filter((p) => !p.data.draft)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
return (
<ul>
{published.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
</li>
))}
</ul>
);
}
Single entry with body:
import { getEntry } from "@mandujs/core/content";
export default async function Post({ params }) {
const post = await getEntry("posts", params.slug);
if (!post) return null;
return (
<article>
<h1>{post.data.title}</h1>
<time>{post.data.date.toDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
post.body is the rendered HTML for markdown entries. post.data is the
parsed, Zod-validated frontmatter.
Generating static params
Combine with Mandu's generateStaticParams to prerender each entry:
// app/blog/[slug]/page.tsx
import { getCollection } from "@mandujs/core/content";
export async function generateStaticParams() {
const posts = await getCollection("posts");
return posts.map((p) => ({ slug: p.id }));
}
export default async function Post({ params }) {
const post = await getEntry("posts", params.slug);
// ...
}
Every post gets its own /blog/<slug> URL prerendered at build time.
Dev mode watching
In dev, the Content Layer watches the filesystem. Editing a Markdown file
re-parses just that entry and HMR-updates any open page. API loaders
respect cacheTTL — set to 0 for no cache during debugging.
This docs site uses it
mandujs.com itself is a Content Layer consumer. Every file under
content/docs/**/*.mdx is a collection entry. The sidebar is built from
buildSidebar() which calls getCollection("docs").
🤖 Agent Prompt
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/architect/content-layer to my project.
Summary of the page:
Mandu Content Layer loads Markdown/JSON/YAML/API content at build time into typed collections. Define collections in content.config.ts using glob/file/api loaders + Zod schemas. Query with getCollection() and getEntry().
Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.
Related
- Rendering modes — where prerender fits
- Prerender — how collections become static HTML
For Agents
Mandu Content Layer loads Markdown/JSON/YAML/API content at build time into typed collections. Define collections in content.config.ts using glob/file/api loaders + Zod schemas. Query with getCollection() and getEntry().