How to build a typed content collection with a Zod frontmatter schema in Astro
When you write blog posts as Markdown or MDX files, each one starts with a block of frontmatter — the title, date, tags, and so on. The problem is that frontmatter is just loose YAML. Nothing stops you from typoing titel, forgetting a description, or writing date: soon. On a static site those mistakes either break the build in a confusing way or silently ship. Astro’s content collections fix this by attaching a schema to your files. Here’s how this blog does it, in one small file.
What a content collection is
A content collection is a named group of content files that Astro loads, validates, and types for you. You tell it where the files live and what shape their frontmatter must have. In return you get build-time validation (bad frontmatter fails the build with a clear message) and fully typed data when you query it — your editor autocompletes post.data.title and complains if you touch a field that doesn’t exist.
The whole configuration lives in src/content.config.ts:
// blog/src/content.config.ts
import { defineCollection } from "astro:content"
import { glob } from "astro/loaders"
import { z } from "astro/zod"
const posts = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
})
export const collections = { posts }Two pieces do the work: the loader (where the content comes from) and the schema (what its frontmatter must look like). Let’s take them one at a time.
The glob loader
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),The glob loader tells Astro to find content by matching file paths. Here it grabs every .md and .mdx file under src/content/posts, at any depth (** matches nested folders). Each matched file becomes one entry in the posts collection, and the file’s path (minus extension) becomes its id — that’s what ends up in the URL later.
Using the glob loader means adding a new post is literally just dropping a new .mdx file into that folder. No registration, no index list to update.
The Zod schema, field by field
schema is where the guarantees come from. It uses Zod, a validation library that ships with Astro as astro/zod. Astro runs each file’s frontmatter through this schema at build time. If a file doesn’t match, the build fails and tells you which file and which field. Here’s what each rule means:
title: z.string()— required text. Miss it, and the build stops.description: z.string()— also required. On this blog it feeds the SEO meta description, so making it mandatory means no post ever ships without one.date: z.coerce.date()— this is the nice one.coercemeans Zod takes the string"2026-05-23"from your YAML and converts it into a real JavaScriptDateobject. So later you can callpost.data.date.getFullYear()or compare dates directly, instead of parsing strings yourself.updatedDate: z.coerce.date().optional()— same coercion, but.optional()means you can leave it out. Use it only when a post has been revised.tags: z.array(z.string()).default([])— an array of strings, and.default([])means a post with notags:line gets an empty array rather thanundefined. That saves you from null-checks everywhere you loop over tags.draft: z.boolean().default(false)— a flag, defaulting tofalse, so posts are published unless you explicitly mark themdraft: true.
The .default(...) calls matter more than they look: they mean the typed data your code receives is never undefined for those fields, so downstream code stays clean. The z.coerce.date() field in particular pays off later — it hands you a real Date that a component like the one in formatting dates consistently across the blog can render without any parsing.
Why typed frontmatter prevents mistakes
Once the schema is in place, two good things happen automatically.
At build time, validation catches errors before they reach production. A typo, a missing description, or date: soon fails the build with a message pointing at the offending file — instead of a cryptic runtime crash or a page that quietly renders wrong.
In your editor, the collection is fully typed. When you query it, the shape flows through:
import { getCollection } from "astro:content"
const posts = await getCollection("posts")
// post.data.title -> string (autocompletes)
// post.data.date -> Date (thanks to z.coerce.date())
// post.data.tags -> string[] (never undefined)
// post.data.subtitle -> type error, no such fieldThat last line is the payoff: reference a field that isn’t in the schema and TypeScript stops you before you ever run the site.
Once the collection is typed like this, everything else on the blog is built on top of it — dynamic post routes with getStaticPaths, related posts and prev/next navigation, and the tag system all query this same collection.
What to remember
- A content collection = a loader (where files live) plus a schema (their frontmatter shape).
- The
globloader picks up every.md/.mdxfile in a folder, so adding a post is just adding a file. z.coerce.date()turns frontmatter date strings into realDateobjects you can work with..default([])and.default(false)mean fields liketagsanddraftare neverundefinedin your code.- The schema validates at build time and types your data in the editor — mistakes surface before they ship.