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:

  • Types — z.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.

A schema is a plain function when it only needs z, but when a field is a local image, Astro passes an extra helper into a schema callback: ({ image }) => z.object({ ... }). Call image() for that field, and the raw path in frontmatter (cover: "./cover.png") becomes a validated, optimized image object at build time:

src/content.config.ts
import { defineCollection, reference } 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: ({ image }) => z.object({
title: z.string(),
author: reference('authors'),
cover: image(), // validates the path AND generates an optimized asset
coverAlt: z.string(),
}),
});
export const collections = { blog };
src/pages/index.astro
---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const post = posts[0];
---
{post && (
<img src={post.data.cover.src} width={post.data.cover.width} height={post.data.cover.height} alt={post.data.coverAlt} />
)}

post.data.cover isn’t a string — it’s { src, width, height, format, ... }, the same shape astro:assets gives <Image>. Proven in the probe: building with cover: "./cover.png" in frontmatter emits a real, hashed, optimized file and bakes its real dimensions into the HTML, with no manual <Image> component needed:

<!-- real build output -->
<img src="/_astro/cover.asuJasgv.png" width="1" height="1" alt="A sunset">

image() resolves the path relative to the entry’s own file, which is why it only works for loaders (like glob) that set a filePath on each entry — a loader that only sets id/data (like the notesLoader from the previous lesson) has nothing for image() to resolve against.

Validation isn’t advisory — a bad entry stops the build cold, and the message tells you exactly which field and which entry. Given this frontmatter with a pubDate that isn’t a real date:

---
title: "Bad Post"
pubDate: "soon"
author: ada
cover: "./cover.png"
coverAlt: "broken"
---

…against the pubDate: z.coerce.date() field from this lesson’s schema, astro sync (and astro dev/astro build, which sync first) fails immediately — this is the real error text on Astro 7.3.5:

[InvalidContentEntryDataError] blog → bad-post data does not match collection schema.
pubDate**: **pubDate: Expected type "date", received "object"
Location:
src/data/blog/bad-post.md:0:0

"soon" coerced through new Date("soon") produces an Invalid Date object, not a string — hence “received object”, not “received string”. The error names the collection (blog), the entry (bad-post), and the field (pubDate), so fixing bad content is a one-line diff, not a debugging session.

Think of the schema as a gate at the collection’s front door, not a type annotation bolted on afterward. Nothing gets into the content store without passing through it first — that’s why a bad entry fails at sync time, before any page has a chance to render it, and why the generated type is never a guess: it’s a direct reflection of the one gate everything already passed through. image() extends the same gate to binary assets — the path in frontmatter is just as “unvalidated” as a string field until the schema resolves and optimizes it.

What you get Benefit Cost
A schema per collection Bad content fails the build with a precise, field-level message You must describe every field up front; an undeclared field is silently dropped from entry.data, not an error
z.coerce.date() / .default() / .optional() Downstream code never juggles raw strings or undefined Coercion can mask a genuinely malformed value as a valid-but-wrong one ("soon" becomes Invalid Date, not a caught string error, until you read the message closely)
reference() A validated, typed link between collections, resolved with one getEntry call The reference is just an id until resolved — reading .data straight off it is a type error, not a silent undefined
image() in a schema callback Local images get optimized and typed with zero extra <Image> boilerplate Only works for loaders that set filePath per entry (glob does; a hand-written in-memory loader typically doesn’t)
  • “A schema just adds TypeScript types.” It validates at build time first — the types are a side effect of that validation succeeding, not a separate step. A field can be perfectly typed and still fail validation (z.string().max(80) typed as string, but an 81-character title still fails the build).
  • “z.coerce.date() either parses correctly or throws immediately.” An unparseable string doesn’t throw inside z.coerce.date() — it produces Invalid Date, which Zod then reports as Expected type "date", received "object". The error is real and build-blocking, but the wording surprises people expecting "received string".
  • “image() works with any loader, since it’s just part of the schema.” It resolves paths relative to the entry’s file, so it needs the loader to record where that file is (filePath). A loader that only sets id/data — like a hand-written API or database loader — has no file for image() to resolve against.
  • “A reference('authors') field already contains the author’s data.” It’s a validated id, not the resolved entry — post.data.author.name is a type error. Call getEntry(post.data.author) first.

When a generated snippet defines a schema or a reference, check for these before trusting it:

  • Is the schema a plain object (schema: z.object({...})) when it uses image()? image() is only available inside the callback form (schema: ({ image }) => z.object({...})) — using it in a plain object schema is a silent reference error, not a type mismatch.
  • Does code read post.data.author.name directly off a reference() field, instead of resolving it first with await getEntry(post.data.author)?
  • Does a .default(...) sit on a field that’s also marked .optional()? The two overlap in confusing ways — .default() alone already makes a field effectively optional in the input.
  • If a date field uses z.coerce.date(), does the surrounding code assume any parse failure throws immediately, rather than producing an Invalid Date that only surfaces as a validation error at the schema boundary?
  • Does the schema forget .default([]) on an array field that’s genuinely optional in the content — leaving every consumer of entry.data.tags to add its own ?? [] fallback?

🐛 Spot the bug

---
title: "Bad Post"
pubDate: "soon"
author: ada
cover: "./cover.png"
coverAlt: "broken"
---
This post has a bad pubDate.
Show the answer

The frontmatter’s pubDate: "soon" is not a parseable date, but the schema for this collection declares pubDate: z.coerce.date(). z.coerce.date() doesn’t reject the unparseable string outright — it runs it through new Date("soon"), which produces a real JavaScript Invalid Date object, and Zod’s validator then reports that object as the wrong type. Proven in the probe on Astro 7.3.5: astro sync fails immediately with [InvalidContentEntryDataError] blog → bad-post data does not match collection schema and the field-level detail pubDate: Expected type "date", received "object" — worth reading closely, since “received object” (not “string”) is exactly the fingerprint of a coercion producing Invalid Date rather than a plain type mismatch. The fix is simply write a real, parseable date in the frontmatter (pubDate: 2026-01-03).

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?
This schema is written as a plain object, not a callback. What happens when Astro loads it?
schema: z.object({
  title: z.string(),
  cover: image(),
}),
With schema field pubDate: z.coerce.date(), what real error does astro sync report on Astro 7.3.5?
---
title: "Bad Post"
pubDate: "soon"
---