If you’ve worked with static site generators before, you know the frontmatter problem. You write a field in your Markdown, reference it in your template, and nothing tells you when they’ve drifted apart. A typo in pubdate instead of pubDate silently renders your date as undefined, and you only catch it when you’re looking at the built site.

Astro’s content collections fix this with a schema layer that validates frontmatter at build time. Not a new idea, but the execution is clean enough to be worth looking at.

How it works

You define a collection in src/content/config.ts:

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

The schema uses Zod, so you get coercion, defaults, optional fields, and useful error messages. The z.coerce.date() call is handy: it accepts ISO strings from your Markdown frontmatter and converts them to proper Date objects automatically.

Querying collections

In your pages, you query the collection with getCollection:

import { getCollection } from 'astro:content';

const posts = await getCollection('blog', ({ data }) => {
  return import.meta.env.PROD ? !data.draft : true;
});

// Sort by date, newest first
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

The filter callback is typed. data has the shape you defined in your schema, so you get autocomplete on data.draft, data.pubDate, and so on. The production check for drafts is a common pattern that fits naturally here.

Rendering a post

For dynamic routes, you generate paths from the collection and render each entry:

// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((entry) => ({
    params: { slug: entry.slug },
    props: { entry },
  }));
}

const { entry } = Astro.props;
const { Content, headings } = await entry.render();

The render() call returns the compiled Content component and a headings array, which is what you need to build a table of contents without parsing Markdown yourself.

What works well

The schema lives in src/content/config.ts, not a separate types file or schema registry. Add a field, everything else catches up.

Validation happens at build time. If your frontmatter doesn’t match the schema, the build fails with a clear error including the file name and what went wrong. You can’t deploy broken content.

TypeScript types flow through automatically. entry.data is typed to match your schema, the filter callback in getCollection has the right types, and you don’t write any of it yourself.

One limitation

getCollection doesn’t support cursor-based pagination out of the box. You filter and sort in memory. For most blogs that’s fine since it’s all build-time work, but worth knowing if you’re dealing with a large number of posts.