ehsan.blog
~/blog/how-to-build-a-typed-content-collection-in-astro — zsh
cat how-to-build-a-typed-content-collection-in-astro.md

How to build a typed content collection with a Zod frontmatter schema in Astro

·5 min read

When you write blog posts as Markdown or MDX files, each one starts with a block of frontmatter — the title, date, tags, and so on. The problem is that frontmatter is just loose YAML. Nothing stops you from typoing titel, forgetting a description, or writing date: soon. On a static site those mistakes either break the build in a confusing way or silently ship. Astro’s content collections fix this by attaching a schema to your files. Here’s how this blog does it, in one small file.

What a content collection is

A content collection is a named group of content files that Astro loads, validates, and types for you. You tell it where the files live and what shape their frontmatter must have. In return you get build-time validation (bad frontmatter fails the build with a clear message) and fully typed data when you query it — your editor autocompletes post.data.title and complains if you touch a field that doesn’t exist.

The whole configuration lives in src/content.config.ts:

ts
// blog/src/content.config.ts
import { defineCollection } from "astro:content"
import { glob } from "astro/loaders"
import { z } from "astro/zod"

const posts = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
})

export const collections = { posts }

Two pieces do the work: the loader (where the content comes from) and the schema (what its frontmatter must look like). Let’s take them one at a time.

The glob loader

ts
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),

The glob loader tells Astro to find content by matching file paths. Here it grabs every .md and .mdx file under src/content/posts, at any depth (** matches nested folders). Each matched file becomes one entry in the posts collection, and the file’s path (minus extension) becomes its id — that’s what ends up in the URL later.

Using the glob loader means adding a new post is literally just dropping a new .mdx file into that folder. No registration, no index list to update.

The Zod schema, field by field

schema is where the guarantees come from. It uses Zod, a validation library that ships with Astro as astro/zod. Astro runs each file’s frontmatter through this schema at build time. If a file doesn’t match, the build fails and tells you which file and which field. Here’s what each rule means:

  • title: z.string() — required text. Miss it, and the build stops.
  • description: z.string() — also required. On this blog it feeds the SEO meta description, so making it mandatory means no post ever ships without one.
  • date: z.coerce.date() — this is the nice one. coerce means Zod takes the string "2026-05-23" from your YAML and converts it into a real JavaScript Date object. So later you can call post.data.date.getFullYear() or compare dates directly, instead of parsing strings yourself.
  • updatedDate: z.coerce.date().optional() — same coercion, but .optional() means you can leave it out. Use it only when a post has been revised.
  • tags: z.array(z.string()).default([]) — an array of strings, and .default([]) means a post with no tags: line gets an empty array rather than undefined. That saves you from null-checks everywhere you loop over tags.
  • draft: z.boolean().default(false) — a flag, defaulting to false, so posts are published unless you explicitly mark them draft: true.

The .default(...) calls matter more than they look: they mean the typed data your code receives is never undefined for those fields, so downstream code stays clean. The z.coerce.date() field in particular pays off later — it hands you a real Date that a component like the one in formatting dates consistently across the blog can render without any parsing.

Why typed frontmatter prevents mistakes

Once the schema is in place, two good things happen automatically.

At build time, validation catches errors before they reach production. A typo, a missing description, or date: soon fails the build with a message pointing at the offending file — instead of a cryptic runtime crash or a page that quietly renders wrong.

In your editor, the collection is fully typed. When you query it, the shape flows through:

ts
import { getCollection } from "astro:content"

const posts = await getCollection("posts")
// post.data.title    -> string  (autocompletes)
// post.data.date     -> Date    (thanks to z.coerce.date())
// post.data.tags     -> string[] (never undefined)
// post.data.subtitle -> type error, no such field

That last line is the payoff: reference a field that isn’t in the schema and TypeScript stops you before you ever run the site.

Once the collection is typed like this, everything else on the blog is built on top of it — dynamic post routes with getStaticPaths, related posts and prev/next navigation, and the tag system all query this same collection.

What to remember

  • A content collection = a loader (where files live) plus a schema (their frontmatter shape).
  • The glob loader picks up every .md/.mdx file in a folder, so adding a post is just adding a file.
  • z.coerce.date() turns frontmatter date strings into real Date objects you can work with.
  • .default([]) and .default(false) mean fields like tags and draft are never undefined in your code.
  • The schema validates at build time and types your data in the editor — mistakes surface before they ship.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.