Astro Content Collections Are Underrated
Content collections give you a type-safe API over your markdown files.
Define a Zod schema once and every frontmatter field is validated at
build time — no more typos silently becoming undefined at runtime.
How it fits together
Here’s the full data flow from markdown files to rendered HTML:
flowchart TD
A[src/content/blog/*.md] -->|validated against schema| B[Content Collection]
B --> C[getCollection]
B --> D[getEntry]
C -->|sorted & filtered| E[Blog index page]
D -->|render| F[render]
F --> G[Blog post page]
E --> H[Static HTML output]
G --> H
Defining the schema
// 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()).optional().default([]),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog };
If a markdown file has a pubDate that can’t be coerced to a date, or
a missing title, Astro throws a build error with the exact file and field.
No silent failures.
Querying collections
getCollection and getEntry are the two main APIs. Both are fully typed
based on your schema definition.
// All non-draft posts, newest first
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
// Single post by slug
const post = await getEntry('blog', Astro.params.slug);
Rendering content
Astro 5 introduced a top-level render() function. The old post.render()
still works but render(post) is the idiomatic new form:
import { render } from 'astro:content';
const { Content, headings } = await render(post);
headings gives you the full heading tree for building a table of contents
— another thing you’d otherwise have to parse yourself.
Highly recommended if you’re building any content-driven Astro site.