Content-heavy sites have a predictable failure mode: markdown files multiply, frontmatter grows inconsistent, and one day a missing pubDate breaks the build in production. Astro’s Content Layer API solves this by bringing schema validation to your local files.
Defining a Collection
Collections live in src/content.config.ts. You declare a Zod schema, and Astro validates every file at build time:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
If a post is missing title, the build fails with a clear error pointing to the exact file. No more silent frontmatter bugs.
Querying Collections
In any .astro page, getCollection returns fully typed entries:
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
The filter function strips drafts. The sort gives you newest-first ordering without a plugin.
Rendering MDX
import { render } from 'astro:content';
const { Content, headings } = await render(entry);
headings is a flat array of every heading element in the document — exactly what you need for a table of contents. I’m not using it yet, but it’s there when the time comes.
Static Route Generation
Pairing collections with getStaticPaths keeps routing tight:
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
The post.id is derived from the filename (type-safe-apis.mdx → type-safe-apis), so your URLs are stable and predictable without any extra configuration.