Skip to content
Astro as content 4 min read

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.

NeedSchemaNotes
Required textz.string()Fails on missing or non-string values
Optional fieldz.string().optional()Allows undefined
Default valuez.boolean().default(false)Fills in when omitted
Constrained stringz.string().min(1).max(160)Length bounds
Enumz.enum(["draft", "published"])Only listed values pass
Numberz.number().int().positive()Integer, > 0
URL / emailz.string().url() / z.string().email()Format validation
Nested objectz.object({ ... })Structured frontmatter

YAML frontmatter has no native date type, but Astro automatically coerces unquoted ISO-8601 date strings into JavaScript Date objects when the field uses z.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.ts as 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 on undefined.
  • 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 real Date objects.
  • Run astro sync after editing a schema to refresh generated types before relying on autocompletion.
Last updated June 14, 2026
Was this helpful?