LangENKO

Content Layer

Astro-style build-time content collections with Zod validation and digest caching.

since v0.25
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

🤖 Agent Prompt — Content Layer
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.

For Agents

AI hint

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().

Guard scope
content