Astro Content Collections Tutorial
Build a typed blog with Astro content collections, including Zod schemas, references, and dynamic routes generated from Markdown files.
What you'll learn
- ✓Defining a content collection with a Zod schema
- ✓Querying entries with getCollection
- ✓Generating routes with getStaticPaths
- ✓Linking entries with references
- ✓Validating frontmatter at build time
Prerequisites
- •Comfortable with HTML and JavaScript
What and Why
A typical content site stores articles as Markdown files. Without help, you end up parsing frontmatter, casting strings into dates, and praying that nobody mistypes a field. Astro content collections solve all three problems. You define a Zod schema, point Astro at a folder, and every entry is validated, typed, and queryable from your pages.
Mental Model
A collection is a folder under src/content/ plus a schema. At build time Astro walks the folder, reads each Markdown or MDX file, validates the frontmatter against the schema, and exposes the result as a typed list. Your pages call getCollection('blog') and receive entries with autocompletion, with the body parsed into renderable content.
src/content/blog/*.md
|
v
frontmatter -> Zod schema -> validated entry
body -> MDX parser -> Astro component
|
v
getCollection('blog') -> typed array in pages
Hands-on Example
Define the collection in src/content/config.ts.
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string().max(120),
description: z.string().min(20).max(200),
publishedAt: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
Add a post file.
---
title: "Hello World"
description: "First post in the new typed content system."
publishedAt: 2026-01-12
tags: ["intro", "astro"]
---
Welcome to the new site.
List published posts on an index page.
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog'))
.filter(p => !p.data.draft)
.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
---
<ul>
{posts.map(p => (
<li>
<a href={`/blog/${p.slug}/`}>{p.data.title}</a>
<time datetime={p.data.publishedAt.toISOString()}>
{p.data.publishedAt.toDateString()}
</time>
</li>
))}
</ul>
Generate a page per post.
---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({ params: { slug: post.slug }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
To connect entries to each other, add a reference. For example, a second collection of authors.
import { defineCollection, reference, z } from 'astro:content';
const authors = defineCollection({
type: 'data',
schema: z.object({ name: z.string(), bio: z.string() }),
});
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
author: reference('authors'),
publishedAt: z.coerce.date(),
}),
});
export const collections = { blog, authors };
The frontmatter then refers to an author by id.
---
title: "Performance Notes"
description: "A short post on perceived performance."
author: "alex"
publishedAt: 2026-01-20
---
In the template, resolve the reference with getEntry.
import { getEntry } from 'astro:content';
const author = await getEntry(post.data.author);
Common Pitfalls
Forgetting z.coerce.date() is a common error. Frontmatter dates come in as strings, so the plain z.date() rejects them. The coerce variant parses ISO strings into Date objects automatically.
Drafts that should not appear in production need explicit filtering. Astro does not hide them for you, even if your schema has a draft boolean. Filter in the index page and inside getStaticPaths so neither the listing nor the route exists for drafts.
A schema change does not retroactively fix existing files. After tightening a field, run astro check or astro build to surface failures across the whole collection rather than discovering them one post at a time.
Practical Tips
Group related collections under a top-level folder. For example, src/content/blog/, src/content/docs/, and src/content/authors/. Each collection has its own schema and can reference the others.
Use Zod transformations to compute derived fields. A schema can add a slug, a reading time, or a canonical URL based on the file path. Your templates then read these computed values without recalculating.
For very large collections, consider Astro’s content layer with a remote loader. It can pull entries from a CMS or a database and treat them as if they were local files, with the same Zod validation.
Keep the schema strict. Disallowing extra fields catches typos in frontmatter before they reach production.
Wrap-up
Content collections give Astro a structured content layer with the type safety of TypeScript and the convenience of Markdown. Define one schema, validate at build time, and your pages can query content with confidence. Add references when entries need to know about each other and your blog graduates into a small CMS without leaving your repo.
Related articles
- Node.js Node.js Zod Validation Tutorial
Use Zod to validate and infer types for request payloads, environment variables, and external data in Node.js apps.
- TypeScript TypeScript with Zod Validation
Use Zod with TypeScript to validate runtime data and infer types from a single source of truth. Cover schemas, parsing, transforms, and error handling.
- Astro Astro Image Component and Optimization
Master Astro's built-in Image and Picture components to ship responsive, lazy-loaded, modern-format images without external services.
- Astro Astro Integrations and Adapters: An Overview
Understand the difference between Astro integrations and adapters, when to reach for each, and how they shape your deployment pipeline.