· astro / mdx / frontmatter
How to write good Astro frontmatter (and validate it)
Add a Zod schema to src/content.config.ts. Frontmatter fields get TypeScript types and build-time validation — no extra dependencies, no runtime surprises.
By Ethan
1,411 words · 8 min read
Add a Zod schema to validate your Astro frontmatter. Without one, entry.data is typed as any and a typo like titel: passes silently until a component throws at runtime. With one, the same mistake is a hard build error — caught before it ships.
This article covers Astro 5 (the current stable majority install). Astro 4 differences are called out inline. A callout at the end covers what changed in Astro 6.
Who this is for
Astro 5 users who have working content collections and want TypeScript inference plus build-time validation on their frontmatter. You need basic TypeScript familiarity — comfort with generic types is enough.
What we tested
Astro 5.18.2 (latest 5.x as of May 2026), Content Layer API. Config API options were version-pinned from the Astro documentation. The Astro 4→5 migration differences come from the official upgrade guide.
Step 1: Move (or create) your config file
The collection config file moved in Astro 5:
src/content.config.ts ← Astro 5+ (correct location)
src/content/config.ts ← Astro 4 (still works with the legacy.collections flag)
If you’re on Astro 5 and your config is still at the old path, Astro will warn you. Move it.
If you have no config file at all, create src/content.config.ts now — the next step fills it in.
Failure mode: a missing config file means no validation and no TypeScript types. entry.data is any. Nothing breaks loudly — it drifts silently.
Step 2: Define your schema
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(), // "2024-01-15" → Date object
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
z is re-exported from astro:content — no separate zod install needed. If you’re evaluating schema validation libraries, see Zod vs Valibot.
z.coerce.date() converts date strings (the YAML type 2024-01-15) to Date objects at build time. Your components receive a proper Date, not a string they have to parse manually.
.default([]) and .default(false) mean authors can omit these fields entirely — if the key is absent, Astro uses the fallback value. Omitting a field with no default is a schema violation.
Failure mode: if the schema field is absent from defineCollection, you get no validation. The collection still works, but entry.data remains any.
Step 3: Use the inferred type in components
Once your schema exists, CollectionEntry<'blog'> gives you a precise TypeScript type anywhere you pass a post:
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
// post.data.title → string ✓
// post.data.publishDate → Date ✓
// post.data.tags → string[] ✓
No casting, no as any. The type is derived from the schema.
The full dynamic route pattern:
// src/pages/blog/[...id].astro
---
import { getCollection, render } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime());
return posts.map(post => ({
params: { id: post.id }, // ← 'id', not 'slug' — Astro 5 change
props: { post },
}));
}
interface Props { post: CollectionEntry<'blog'> }
const { post } = Astro.props;
const { Content } = await render(post); // ← standalone render() — Astro 5 change
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
Astro 4 readers: two things changed. entry.slug became entry.id. entry.render() became the standalone render(entry) imported from astro:content. These are the two breaking changes that affect most templates.
Failure mode: getCollection() returns entries in an unspecified order in Astro 5. If you skip the .sort(), post order will differ between local dev and CI. Always sort explicitly.
Step 4: Add CI validation
astro build validates all collection entries automatically. A schema violation halts the build:
blog → posts/my-post.md frontmatter does not match collection schema.
'title' is required.
'publishDate' must be a valid date.
To catch this before a build, run:
npx astro check
astro check does TypeScript type-checking and content collection validation without compiling the full site. It takes seconds and catches typos before you push.
Add it to CI:
# .github/workflows/ci.yml
- name: Check types and content
run: npx astro check
Failure mode: without this in CI, a content author can push invalid frontmatter to the branch, and it won’t be caught until someone runs astro build. In a monorepo where the build only runs on deploys, that can be a long feedback loop.
Pitfalls
image() requires a function-schema wrapper
If you want Astro’s image optimization pipeline to process a cover field, use image() instead of z.string():
// Wrong — stores a string, skips image optimization
cover: z.string()
// Correct — transformed to ImageMetadata, works with <Image />
cover: image()
image() requires the schema option to be a function, not a plain object:
schema: ({ image }) =>
z.object({
// ...
cover: image().optional(),
}),
Using image() inside a plain z.object({...}) throws a build error. Images must also live under src/ — images in public/ bypass the asset pipeline.
One current limitation: image().refine() is unsupported in the Astro 5 Content Layer API. Custom validation on image fields has to happen at runtime.
.optional() vs. .nullable() vs. .default()
These are not the same:
// .optional() — field may be absent entirely (type: Date | undefined)
updatedDate: z.coerce.date().optional()
// .nullable() — field must be present but can be null (type: string | null)
hero: z.string().nullable()
// .default() — absent field gets a fallback; undefined never appears in output
draft: z.boolean().default(false)
In frontmatter, most “optional” fields should use .optional() or .default() — content authors omit the field entirely. Use .nullable() only when your CMS or tooling explicitly writes null for absent fields.
Cross-collection references
reference() validates that a frontmatter field points to an existing entry in another collection:
import { defineCollection, z, reference } from 'astro:content';
const blog = defineCollection({
// ...
schema: z.object({
author: reference('authors'), // validated at build time
}),
});
A reference to a non-existent author ID fails at build time. This is the right behavior — better a build error than a missing author page in production.
The layout field is gone
Astro 5 removed the special layout frontmatter field that auto-wrapped content collection entries in a layout component. If you’re migrating from Astro 4 and your frontmatter has layout: '../layouts/BlogPost.astro', it will land in entry.data.layout as a plain string and do nothing. Import the layout explicitly in your dynamic route file.
What good frontmatter unlocks
Once frontmatter is schema-typed and build-validated, downstream integrations work without extra glue:
| Feature | Fields needed |
|---|---|
| RSS feed | title, description, publishDate, author |
| Sitemap | publishDate, updatedDate (lastmod), draft (exclude flag) |
| OG images | title, description, cover |
| Related posts | tags (overlap), translationKey (i18n sibling) |
| Author pages | author via reference('authors') |
Having updatedDate as a Date object in your schema gives integrations a clean value to work with. Note that @astrojs/sitemap cannot read page source code directly — an Astro Integration API limitation — so setting lastmod in the sitemap requires a custom serialize() callback in the sitemap config options. Similarly, @astrojs/rss expects frontmatter fields to be mapped explicitly in your feed route handler rather than reading them automatically.
Astro 6 (March 2026): The
legacy.collectionsflag was removed. The Content Layer API is the only supported path. If you’re upgrading, see the Astro v6 upgrade guide and our Astro 6 review. Note for custom loader authors: theschemafunction callback in theLoaderinterface is replaced by thecreateSchema()property — this is scoped to loader implementations, not regulardefineCollectionconfigs.
Caveats
- MDX frontmatter isn’t part of the MDX spec. It’s Astro-specific, added via
remark-frontmatter. Raw@mdx-js/mdxcompilation won’t parse the---block without the same plugin injected manually. image().refine()is currently unsupported in the Content Layer API — a known limitation as of Astro 5.18.2.