Astro Content Collections: typing blog content with Zod before it reaches production
A blog built on Astro with loose markdown files works fine for the first few months. Every post is a file with frontmatter at the top and content below, and the pages read it with Astro.glob() or import.meta.glob(), trusting that every frontmatter field is written the way the code expects. The problem does not show up in the first post, or the tenth. It shows up once the number of files grows, more people start writing content, and a field ends up written differently from one file to the next: date in one post, pubDate in another, a date written as free text instead of an actual date, a tags field that is a comma-separated string in one post and already an array in the next. None of this fails when the file is saved. It fails at runtime, when a page tries to call post.data.pubDate.getFullYear() on a value that turns out to be a string, or when tags.map() blows up because tags is not an array.
Why schema-less frontmatter fails late
Loose markdown has no mechanism to validate its own structure. TypeScript can type the component that consumes the data, but it cannot type the contents of a .md file that gets read dynamically through a glob: as far as TypeScript is concerned, the result of that glob is, at best, any, or a generic type that knows nothing about the actual fields each post has. A misspelled field is not caught while writing the code, and it is not caught by tsc either. It gets caught -if it gets caught at all- when that specific page renders, whether that is in local development if someone happens to visit it, or in production if nobody visited it before the deploy. On an SSR blog this is particularly awkward, because the error does not break the build: the app deploys without complaint, and the broken route only shows itself once someone requests that exact URL.
Content Collections: the schema lives in code, not in convention
Content Collections fixes this by moving frontmatter validation away from "trust that every file follows the same convention" and toward "declare the schema once and let Astro reject any file that does not match it." The schema is defined with Zod in src/content.config.ts:
import { defineCollection, z, reference } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
excerpt: z.string().max(280),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
tags: z.array(z.string()).min(1),
author: reference('authors'),
}),
});
export const collections = { blog };
Every field has a declared type, and Zod does not just check the type, it can enforce further rules too: excerpt cannot exceed 280 characters, tags cannot be an empty array, draft falls back to a default when a file omits it. The author field is not a free-form string but a typed reference into another collection, authors, which means that if a post points to an author that does not exist in that collection, Astro flags it as an error instead of letting a dangling string through that never resolves to anything.
The difference against a loose glob is not just cosmetic. The moment astro build or astro check runs, Astro validates every entry in the collection against the schema and fails the entire build if any entry does not comply, with a message pointing at the exact file and field that failed validation. A pubDate written as "March 14th" instead of an actual date stops being a silent bug that only surfaces when someone visits that page, and becomes a build failure that blocks the deploy until it is fixed.
The detail that actually matters: date coercion
The easiest place to get the schema wrong is the date field. YAML lets you write a frontmatter date two different ways, and Zod treats them differently: pubDate: 2026-03-14 without quotes, which YAML parses as a native Date object, or pubDate: "2026-03-14" in quotes, which YAML parses as a plain string. If the schema uses plain z.date(), the first form passes validation without issue, but the second one fails, because z.date() expects to receive an actual Date object already, not a string that merely looks like one. Since in practice there is no way to guarantee the whole team will always write unquoted dates -and some markdown editors add quotes automatically- the version that behaves consistently is z.coerce.date(), which accepts either the native Date object or a parseable string and normalizes both to a Date. Swapping z.date() for z.coerce.date() in the schema above removes an entire class of build failures that have nothing to do with the actual content of the post, and everything to do with whether someone happened to quote the date or not.
Consuming the collection with types already resolved
On the page side, getCollection returns entries with the schema's type already applied, with no need to cast or re-validate:
import { getCollection, render } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
The second argument to getCollection filters at load time, not afterward, so a post with draft: true never gets built as a public page in the first place. And since data.pubDate is already a real Date thanks to coercion, .valueOf() just works, with no defensive new Date(post.data.pubDate) needed at every spot that touches a date -which is exactly the kind of repeated boilerplate the loose glob forced onto every page that dealt with dates.
What did not work: hand-rolled validation with a guard function
Before migrating to Content Collections we tried a middle ground: keep the loose glob, but add an isValidFrontmatter() function that checked the expected fields before rendering each post and logged a warning if something did not match. The idea was to catch the problem without rewriting how content was read. It did not work, for two reasons. First, the guard function lived apart from where the data was actually used, so every time a new frontmatter field was added, someone had to remember to update the validation function too, and in practice the two drifted apart: the function kept checking fields that were no longer used and let new, unchecked fields through. Second, a warning logged during the build blocks nothing: the deploy goes ahead regardless, and a warning that blocks nothing ends up being a warning nobody reads, especially in a pipeline with a lot of other output. Content Collections solves both problems at once, because the schema and the content are coupled by design -there is no way for them to drift apart without the build noticing- and because a failed validation is a build error, not an optional log line.
When the migration is worth it
For a blog with a handful of posts and a single person writing them, the cost of defining a Zod schema may not pay for itself yet: frontmatter mistakes are rare because the same person writing the content already knows the convention by heart. The migration starts paying for itself the moment a second person starts writing content, or once the number of frontmatter fields grows past what anyone can reliably keep in their head. At that point, the relevant question is not whether a frontmatter mistake will happen, but whether it gets caught at build time, before anyone sees it, or in production, when someone requests the broken page.