Skip to content

Schemas & References

Content is data that lives outside your code — Markdown files edited by hand, JSON from a CMS. Nothing stops a typo: a missing title, a pubDate written as "soon", a tag that should be an array but is a string. Without validation, that mistake surfaces as a confusing runtime error deep in a template, or worse, a silently broken page.

A schema moves that check to the content boundary: when Astro loads a collection, it validates every entry against the schema before your pages ever see it. A bad entry fails the build with a precise message (“pubDate expected a date”), not a mystery crash. Validated content is also typed content — which is the second payoff.

Astro uses Zod for schemas. You get Zod from astro/zod and describe the frontmatter shape field by field:

src/content.config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string().max(80),
description: z.string().optional(), // may be omitted
pubDate: z.coerce.date(), // "2026-01-01" becomes a Date
draft: z.boolean().default(false), // fills in if absent
tags: z.array(z.string()).default([]),
status: z.enum(['draft', 'published', 'archived']),
}),
});
export const collections = { blog };

The common building blocks:

  • Typesz.string(), z.number(), z.boolean(), z.array(...), z.enum([...]).
  • .optional() — the field may be missing; its type becomes T | undefined.
  • .default(value) — if the field is absent, fill in this value (so downstream code never handles undefined).
  • z.coerce.date() — parse a date string from frontmatter into a real Date.

Refinements like .max(80) or .url() let you enforce real constraints — a title length, a valid URL — so bad content is caught at build.

Because a schema fully describes the frontmatter, Astro generates TypeScript types from it. When you write post.data., your editor autocompletes title, pubDate, tags — with the right types. post.data.pubDate is a Date; post.data.status is the union 'draft' | 'published' | 'archived'. You never hand-write a Post interface; the schema is the source of truth for both validation and types.

flowchart TB
  schema["Zod schema in content.config.ts"] --> validate["validate every entry at build"]
  schema --> types["generate TypeScript types"]
  validate --> safe["bad content fails the build early"]
  types --> dx["autocomplete and type-checked entry.data"]
One schema, two payoffs

Content is rarely flat. A blog post has an author; a doc page belongs to a category. Rather than copy the author’s details into every post, you keep an authors collection and have each post reference an author by id. Astro’s reference() helper (from astro:content) expresses that link and validates it:

src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { z } from 'astro/zod';
import { glob, file } from 'astro/loaders';
const authors = defineCollection({
loader: file('./src/data/authors.json'),
schema: z.object({ name: z.string(), bio: z.string() }),
});
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string(),
author: reference('authors'), // must match an id in the authors collection
related: z.array(reference('blog')).default([]),
}),
});
export const collections = { authors, blog };

reference('authors') stores the author’s id and guarantees it points at a real entry. To turn that reference into the actual data, resolve it with getEntry:

---
import { getEntry, getCollection } from 'astro:content';
const post = (await getCollection('blog'))[0];
const author = await getEntry(post.data.author); // pass the reference straight in
---
<p>By {author.data.name}</p>

This keeps content normalized — author details live in one place — while giving each post a validated, resolvable link.

What does a schema give you beyond catching bad content?
What does .default(false) do on a schema field?
What is reference() used for?
Where does content validation happen?