Data, content, and assets

Validate content data

Give content fields a schema so a missing title, invalid date, or wrong value fails before deployment.

A collection loads entries. A schema says what a valid entry looks like. Add one and Astro checks every file for you at build time.

Astro uses Zod for schemas. Here is one for the notes collection:

import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'

const notes = defineCollection({
  loader: glob({ base: './src/content/notes', pattern: '**/*.md' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    publishedAt: z.coerce.date(),
    topic: z.enum(['astro', 'css', 'javascript']),
    draft: z.boolean().default(false)
  })
})

export const collections = { notes }

Read it top to bottom. title and description must be strings. publishedAt is coerced into a real Date, so 2026-09-07 in the frontmatter becomes a Date object in data.publishedAt, and you can sort by it. topic must be one of three values. draft is a boolean, and if omitted it’s false.

A valid entry looks like this:

---
title: Scoped styles in Astro
description: How Astro keeps component CSS local.
publishedAt: 2026-09-07
topic: astro
---

No draft field. The default fills it in.

Break it on purpose

Now change topic: astro to topic: cooking and run the build:

npm run build

The build stops with an InvalidContentEntryDataError. It names the collection, the file, and the field: topic received cooking, expected astro, css, or javascript. Fix the file and it builds.

This is the payoff. Without a schema, the bad value flows into your templates. Maybe a filter silently drops the note. Maybe a topic page 404s. You find out from a reader. With a schema, you find out before deploying, at the exact line.

Required, optional, default

Make a field required when every template needs it. title is required because a listing without titles is broken.

Use .optional() only for real variants, like a canonicalUrl most notes don’t have. Don’t use it to avoid cleaning up inconsistent frontmatter. That moves the mess from the content into every template.

Use .default() when a missing value has one obvious meaning. A note with no draft field is published. That’s unambiguous, so a default fits.

The schema also gives you types. In a page, note.data.topic is the union 'astro' | 'css' | 'javascript', not string. Misspell a topic in a comparison and the editor tells you.

A good schema is strict enough to catch mistakes and honest enough to describe the content you actually have. When they disagree, fix the content, not the schema.

Lesson completed