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.

← All posts