
How to define a content collection schema with Zod in Astro?
To define a content collection schema with Zod in Astro, import z from astro/zod and pass a z.object({...}) as the schema property of defineCollection() in src/content.config.ts. Each key in the object describes one frontmatter field, such as title: z.string() or pubDate: z.coerce.date(), and you chain methods like .optional(), .default() and .max() to make the rules stricter or looser. When Astro syncs your content, every entry is checked against the schema; invalid entries fail the build with a message naming the file and field, and valid entries get TypeScript types generated from the schema automatically.
Astro 6 moved content collections to Zod 4, which changed a few familiar APIs, so older tutorials will mislead you in small but annoying ways. In this article you'll learn the basic schema structure, the field types you'll use for a blog, how to handle dates, defaults, enums and images, how to transform and cross-check data, what validation errors look like, and how to share schemas between collections. Every example was built and type-checked with Astro 7.4.
The Basic Structure
A schema lives next to the loader in each collection definition:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
}),
});
export const collections = { blog };
A few points matter here:
- Import z from astro/zod: it's a re-export of Zod 4, so you don't need to install Zod yourself and you're guaranteed to use the same version as Astro.
- The schema describes frontmatter: for Markdown and MDX, it validates the YAML between the
---lines. For JSON or YAML collections, it validates each object's fields. - Every field must be declared: when you provide a schema, the validated
dataobject only contains the fields the schema defines. - The schema is optional: without one,
entry.datais typed asany. With one, it's fully typed.
If you're new to collections themselves, start with what content collections are in Astro.
Common Field Types
These are the validators that cover almost every frontmatter field you'll write:
| Frontmatter | Zod schema | Notes |
|---|---|---|
title: "Hello" | z.string() | Add .min() or .max() for length |
order: 3 | z.number() | Add .int() or .positive() |
draft: true | z.boolean() | Often combined with .default(false) |
pubDate: 2026-10-01 | z.coerce.date() | Produces a Date object |
tags: ["a", "b"] | z.array(z.string()) | Arrays of any type |
category: guides | z.enum(["guides", "news"]) | Only listed values allowed |
email: a@b.com | z.email() | Zod 4 top-level format |
canonical: https://... | z.url() | Zod 4 top-level format |
seo: { title: ... } | z.object({ ... }) | Nested objects |
String Formats Moved in Zod 4
In Zod 3 you wrote z.string().email() and z.string().url(). Zod 4 moved these formats to the top level, so the current style is z.email() and z.url(). The old chained forms are deprecated, which means new code should use the top-level ones.
Custom Error Messages Use error
The other Zod 4 change you'll notice is the name of the option for custom messages. It's now error, not message:
description: z.string().min(50, { error: "Write a longer description" }),
You can also pass the message as a plain string, as in z.string().min(50, "Write a longer description").
A Complete Blog Schema
Here's a realistic schema for a blog, the one I used to test the examples in this article:
// src/content.config.ts
import { defineCollection, reference } from "astro:content";
import { glob, file } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
schema: ({ image }) =>
z.object({
title: z.string().max(80),
description: z.string().min(50, { error: "Write a longer description" }),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
category: z.enum(["guides", "news", "opinion"]),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
cover: image().optional(),
author: reference("authors"),
relatedPosts: z.array(reference("blog")).default([]),
}),
});
const authors = defineCollection({
loader: file("src/data/authors.json"),
schema: z.object({
name: z.string(),
email: z.email(),
website: z.url().optional(),
}),
});
export const collections = { blog, authors };
The rest of this article walks through the decisions in it.
Required, Optional and Default Fields
Every field is required unless you say otherwise. There are three ways to relax that:
- .optional(): the field may be missing, and its type becomes
T | undefined. Use it for things likeupdatedDatewhere "not set" is meaningful. - .default(value): the field may be missing, and Zod fills in the value. The resulting type is
T, neverundefined, so you don't need?? []checks in your templates. - .nullable(): the field may be explicitly
null. This is rarely needed for frontmatter but can be useful for JSON from an API.
Prefer .default() for arrays and booleans. With tags: z.array(z.string()).default([]), every post has a tags array, which makes code like post.data.tags.includes("astro") safe everywhere.
Handling Dates
Dates trip people up more than any other field. YAML already parses an unquoted value like 2026-10-01 into a date, but a quoted string, a value from JSON, or a date with a time can arrive as a string. z.coerce.date() handles all of these by passing the value through new Date() before validating it:
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
In your pages the value is a real Date, so you can sort and format it directly:
---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";
const posts = (await getCollection("blog")).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---
{posts.map((post) => (
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString("en-GB")}
</time>
))}
A bare date like 2026-10-01 is interpreted as midnight UTC. If your site formats dates in a timezone behind UTC, a post can appear to be published the day before. Format with an explicit timeZone option, or include a time in the frontmatter, if that matters for you.
Restricting Values With Enums
z.enum() is the simplest way to stop typos in categories, statuses or layouts:
category: z.enum(["guides", "news", "opinion"]),
The generated type is the union "guides" | "news" | "opinion", so a comparison like post.data.category === "guide" is flagged by TypeScript before you even build. Enums are also handy when you build category pages, which I cover in how to add tags and category pages to an Astro blog.
Validating Images With image()
For cover images stored next to your posts, make the schema a function. Astro passes it a context object containing an image() helper:
schema: ({ image }) =>
z.object({
title: z.string(),
cover: image().optional(),
}),
In frontmatter, the path is relative to the Markdown file:
---
title: "Content collections in practice"
cover: "./cover.png"
---
image() checks that the file exists, imports it and replaces the string with image metadata (source, width, height and format). That object can go straight into Astro's <Image /> component:
---
// src/pages/covers.astro
import { Image } from "astro:assets";
import { getEntry } from "astro:content";
const post = await getEntry("blog", "second-post");
---
{post?.data.cover && <Image src={post.data.cover} alt="" width={400} />}
In my test build that produced an optimised WebP with the correct width and height attributes:
<img src="/_astro/cover.CNTw1LlI_1PRyXe.webp" alt loading="lazy" decoding="async" width="400" height="225">
One limitation from the docs: custom checks with image().refine() aren't supported. For more on the component itself, see how to use the Astro Image component.
Referencing Other Collections
The author and relatedPosts fields use reference() from astro:content. It validates that the value is an ID and turns it into an object like { collection: "authors", id: "ada" }, which you then resolve with getEntry() or getEntries(). It deserves its own article, so see how to reference one collection from another for the full picture.
Transforming Data
Zod doesn't only check data. It can reshape it, and the transformed value is what ends up in entry.data. This normalises tags to lowercase so "Astro" and "astro" become the same tag:
tags: z
.array(z.string().transform((tag) => tag.toLowerCase()))
.default([]),
Other useful transforms include trimming strings, splitting a comma-separated string into an array, or turning a relative path into an absolute URL. Keep transforms simple and predictable, since they run for every entry on every content sync.
Cross-Field Checks With refine()
Some rules involve more than one field. For example, an updatedDate should never be earlier than pubDate. You can attach .refine() to the whole object and point the error at a specific field with path:
schema: ({ image }) =>
z
.object({
title: z.string().max(80),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
// ...other fields
})
.refine((data) => !data.updatedDate || data.updatedDate >= data.pubDate, {
error: "updatedDate cannot be earlier than pubDate",
path: ["updatedDate"],
}),
When I set a post's updatedDate to January and its pubDate to October, the build stopped with:
[InvalidContentEntryDataError] blog → second-post data does not match collection schema.
updatedDate: updatedDate cannot be earlier than pubDate
What Validation Errors Look Like
Validation happens during content sync, which runs at the start of astro dev, astro build and astro sync. An entry with a short description and a category that isn't in the enum produced this:
[InvalidContentEntryDataError] blog → bad data does not match collection schema.
description: Write a longer description
category: Invalid option: expected one of "guides"|"news"|"opinion"
The error names the collection, the entry ID and each failing field, and gives the file location below it. The build stops, which is the point: a broken post never reaches production. Common causes and fixes:
- A required field is missing: add it to the frontmatter, or mark it
.optional()or give it a.default(). - The wrong type:
tags: "astro"is a string, not an array. Writetags: ["astro"]. - An unparseable date:
z.coerce.date()can't turnnot-a-dateinto a date. Use theYYYY-MM-DDformat. - A value outside an enum: check the spelling, or add the new value to the enum.
Sharing Schemas Between Collections
Schemas are plain values, so you can build them from pieces. A shared SEO object is a good example:
// src/content.config.ts (excerpt)
const seo = z.object({
metaTitle: z.string().max(60).optional(),
metaDescription: z.string().max(160).optional(),
noindex: z.boolean().default(false),
});
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.md" }),
schema: z.object({
title: z.string(),
seo: seo.optional(),
}),
});
const docs = defineCollection({
loader: glob({ base: "./src/content/docs", pattern: "**/*.md" }),
schema: z.object({
title: z.string(),
sidebarOrder: z.number().int().default(0),
seo: seo.optional(),
}),
});
If a shared schema needs the image() helper, write it as a function that takes the SchemaContext type from astro:content, then call it inside each collection's schema function.
Using the Generated Types
Astro generates types from your schemas, and you get them through CollectionEntry:
// src/lib/posts.ts
import { getCollection, type CollectionEntry } from "astro:content";
export type Post = CollectionEntry<"blog">;
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection("blog", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
return posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
}
post.data.pubDate is a Date, post.data.tags is string[], post.data.category is the enum union, and post.data.updatedDate is Date | undefined. Running npx astro check on the test project with these files reported 0 errors. Use the same Post type to type component props, as shown in how to type props in Astro with TypeScript.
If your editor doesn't see a new field, the types are stale. Run npx astro sync, or restart the dev server.
JSON Schema for Data Files
Astro also generates a JSON Schema file for each collection in .astro/collections/, for example .astro/collections/authors.schema.json. Point to it from a JSON data file with a $schema property and your editor will validate and autocomplete fields as you type:
{
"$schema": "../../.astro/collections/authors.schema.json",
"name": "Ada Lovelace",
"email": "ada@example.com"
}
The path is relative to the data file, so adjust the number of ../ segments to suit your folder structure.
FAQ: Content Collection Schemas
Import it from astro/zod. It's a re-export of Zod 4 that matches the version Astro uses internally, so you don't need to install Zod and you avoid version mismatches between your schema and Astro.
Zod 4 moved string formats to the top level. Use z.email(), z.url() and similar validators instead of chaining them on z.string(). The old forms still exist but are deprecated.
Both allow a field to be missing. optional() leaves it undefined, so its type includes undefined. default() fills in a value you choose, so the type never includes undefined and you can use the field without checks.
z.coerce.date() converts strings and other inputs into a Date before validating, so it works for quoted dates, JSON values and date-time strings. z.date() only accepts a value that is already a Date.
During content sync, which runs when you start astro dev, run astro build or run astro sync. Invalid entries stop the build with an InvalidContentEntryDataError that names the file and each failing field.
Yes. Zod transforms such as .transform() run during validation, and the transformed value is what appears in entry.data. This is useful for normalising tags or converting formats, but keep transforms simple and fast.
Conclusion
A content collection schema is a z.object() from astro/zod that describes each frontmatter field. Use z.coerce.date() for dates, .default() for arrays and booleans, z.enum() for fixed sets of values, image() for local cover images and reference() for links between collections. Remember the Zod 4 changes: top-level formats like z.email() and the error option for custom messages.
The payoff is twofold. Mistakes in frontmatter stop the build with a precise message instead of shipping a broken page, and every field arrives in your templates with an accurate TypeScript type. Start with the handful of fields every post needs, add transforms and cross-field checks only when you have a real rule to enforce, and run astro sync whenever the types seem out of date.


