· 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

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:

FeatureFields needed
RSS feedtitle, description, publishDate, author
SitemappublishDate, updatedDate (lastmod), draft (exclude flag)
OG imagestitle, description, cover
Related poststags (overlap), translationKey (i18n sibling)
Author pagesauthor 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.collections flag 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: the schema function callback in the Loader interface is replaced by the createSchema() property — this is scoped to loader implementations, not regular defineCollection configs.


Caveats

  • MDX frontmatter isn’t part of the MDX spec. It’s Astro-specific, added via remark-frontmatter. Raw @mdx-js/mdx compilation 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.

References