Collection Schemas with Zod
A collection schema is the contract for every entry in a collection. By describing the expected shape of your frontmatter (or data) with Zod, Astro validates each entry at build time, fails loudly when something is malformed, and hands you a fully typed object when you query the collection. The result is content that behaves like strongly typed data instead of loosely structured Markdown.
Defining a schema
Schemas are passed to defineCollection via the schema option in src/content.config.ts. Astro re-exports Zod as z from astro:content, so you never install it separately.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string().max(160),
pubDate: z.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };
Every entry’s frontmatter is now checked against this object. A missing title, a description over 160 characters, or a pubDate that isn’t a valid date will halt the build with a precise, file-scoped error message.
Common field types
Zod covers the primitives you reach for most often, plus refinements that encode real constraints.
| Need | Schema | Notes |
|---|---|---|
| Required text | z.string() | Fails on missing or non-string values |
| Optional field | z.string().optional() | Allows undefined |
| Default value | z.boolean().default(false) | Fills in when omitted |
| Constrained string | z.string().min(1).max(160) | Length bounds |
| Enum | z.enum(["draft", "published"]) | Only listed values pass |
| Number | z.number().int().positive() | Integer, > 0 |
| URL / email | z.string().url() / z.string().email() | Format validation |
| Nested object | z.object({ ... }) | Structured frontmatter |
YAML frontmatter has no native date type, but Astro automatically coerces unquoted ISO-8601 date strings into JavaScript
Dateobjects when the field usesz.date(). Quote a date in YAML and it becomes a string — the schema will then reject it.
Transforming values
Zod’s .transform() lets you derive new shapes from raw input, so the typed entry you query is cleaner than the file on disk. A reading-time estimate or a normalized slug can be computed once, at validation time.
schema: z.object({
title: z.string(),
// Accept a comma-separated string OR an array, always output string[]
tags: z
.union([z.string(), z.array(z.string())])
.transform((value) =>
Array.isArray(value)
? value
: value.split(",").map((t) => t.trim()),
),
// Coerce a string into a number with a sensible default
weight: z.coerce.number().default(0),
});
z.coerce.number() parses "42" into 42, which is handy because every value flowing in from a CMS loader or a JSON file may arrive as a string.
Referencing other collections
The reference() helper validates that a field points at a real entry in another collection. The stored value is the referenced entry’s id, and you resolve it later with getEntry.
import { defineCollection, reference, z } from "astro:content";
const authors = defineCollection({
loader: glob({ pattern: "**/*.json", base: "./src/content/authors" }),
schema: z.object({ name: z.string(), avatar: z.string().url() }),
});
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
author: reference("authors"),
related: z.array(reference("blog")).default([]),
}),
});
export const collections = { authors, blog };
If a post names an author that doesn’t exist, the build fails — no more broken cross-references slipping into production.
Validating images
Inside a glob loader, the schema can be a function that receives an image helper. This validates that the referenced asset exists and returns a typed ImageMetadata object ready for Astro’s <Image /> component.
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: ({ image }) =>
z.object({
title: z.string(),
cover: image(),
coverAlt: z.string(),
}),
});
End-to-end type safety
Once a schema is defined, Astro generates types for the collection. Querying an entry gives you autocompletion and compile-time checking on entry.data with no manual interfaces.
---
import { getCollection } from "astro:content";
const posts = await getCollection("blog");
// post.data.pubDate is a Date; post.data.tags is string[]
const sorted = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---
<ul>
{sorted.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
</li>
))}
</ul>
If you reference post.data.titel or treat pubDate as a string, the TypeScript compiler and editor flag it immediately. Run astro sync (or any astro dev/build) to regenerate types after changing a schema.
Output:
[content] Syncing content
[content] Synced content
[types] Generated 1ms
Best practices
- Keep schemas in
src/content.config.tsas the single source of truth; never duplicate the shape in hand-written interfaces. - Prefer
.default()over.optional()when a sensible fallback exists, so downstream code never branches onundefined. - Use
z.enum()for status- and category-style fields to prevent typos from reaching the page. - Reach for
reference()instead of free-form strings whenever one entry links to another — broken links become build errors. - Validate dates with
z.date()and leave them unquoted in YAML so they coerce to realDateobjects. - Run
astro syncafter editing a schema to refresh generated types before relying on autocompletion.