Schemas & References
Why validate content at all
Section titled “Why validate content at all”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.
Zod schemas
Section titled “Zod schemas”Astro uses Zod for schemas. You get Zod from astro/zod and describe the frontmatter shape field by field:
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 becomesT | undefined..default(value)— if the field is absent, fill in this value (so downstream code never handlesundefined).z.coerce.date()— parse a date string from frontmatter into a realDate.
Refinements like .max(80) or .url() let you enforce real constraints — a title length, a valid URL — so bad content is caught at build.
Schemas generate types
Section titled “Schemas generate types”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"]
Linking collections with reference()
Section titled “Linking collections with reference()”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:
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.
Images in schemas: the image() helper
Section titled “Images in schemas: the image() helper”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:
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 };---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.
When a schema fails: the real build error
Section titled “When a schema fails: the real build error”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: adacover: "./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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| 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) |
Common Misconceptions
Section titled “Common Misconceptions”- “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 asstring, but an 81-character title still fails the build). - “
z.coerce.date()either parses correctly or throws immediately.” An unparseable string doesn’t throw insidez.coerce.date()— it producesInvalid Date, which Zod then reports asExpected 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 setsid/data— like a hand-written API or database loader — has no file forimage()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.nameis a type error. CallgetEntry(post.data.author)first.
Reading AI-generated code
Section titled “Reading AI-generated code”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 usesimage()?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.namedirectly off areference()field, instead of resolving it first withawait 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 anInvalid Datethat 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 ofentry.data.tagsto add its own?? []fallback?
🐛 Spot the bug
---title: "Bad Post"pubDate: "soon"author: adacover: "./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).