Typed content collections in Astro

Typed content collections in Astro

Zod schemas, build-time validation, and what the draft flag actually does

By Roger Rajaratnam 1 April 2026
SeriesPart of How this blog was built — documenting every decision that shaped this site.

One of the things I wanted to get right early on this blog was the content model. Markdown is flexible to the point of being dangerous — nothing stops you from publishing a post with a missing title, a malformed date, or a cover image path that leads nowhere. On a small site this sounds manageable. In practice, these problems compound.

Astro content collections solve this with Zod schema validation at build time. If the content doesn’t match the schema, the build fails loudly instead of deploying silently broken content.

How the collection is defined

Everything lives in src/content.config.ts. The defineCollection call takes a loader (which describes where to find the files) and a schema:

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", "!README.md"], base: "./collections/posts" }),
schema: ({ image }) =>
z.object({
title: z.string(),
subTitle: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
author: z.string(),
cover: z
.object({
image: image(),
alt: z.string(),
})
.optional(),
tags: z.array(z.string()),
draft: z.boolean().default(false),
history: z
.array(
z.object({
datetime: z.coerce.date(),
note: z.string(),
}),
)
.optional(),
credits: z
.array(
z.object({
label: z.string(),
text: z.string(),
url: z.string().url().optional(),
}),
)
.optional(),
}),
});
export const collections = { posts };

A few things worth noting:

  • z.coerce.date() means a YAML string like 2026-03-26T00:00:00 is automatically coerced to a JavaScript Date object. You get proper date comparison and formatting in templates without any manual parsing.
  • image() is a special helper provided by Astro’s schema context. It validates that the referenced file exists on disk and returns a typed object with the processed src. Astro passes this through its image optimisation pipeline.
  • draft: z.boolean().default(false) means the draft field is optional in frontmatter — omitting it defaults to false, so only posts that explicitly declare draft: true need the field.
  • credits URLs use z.string().url(), so a malformed URL will fail the build rather than silently render a broken link.

Where the posts live

The glob loader uses a base path outside src/ — the posts are in collections/posts/ at the project root. Each post gets its own directory:

collections/
posts/
why-astro/
index.md
comments-system/
index.md
comments-system-cover.jpg

Co-locating the cover image with the Markdown file is simpler than managing a separate public/ directory for post images. Astro’s image pipeline picks them up automatically when the schema uses image().

The post id that Astro assigns is derived from the directory structure — why-astro for the post above. That becomes the URL slug via the [id].astro dynamic route.

Querying the collection

In any .astro file or API route, getCollection("posts") returns a typed array of posts whose data property matches the schema:

import { getCollection } from "astro:content";
const allPosts = await getCollection("posts");

Every access to post.data.title, post.data.pubDate, or post.data.tags is fully typed. If the schema changes, TypeScript catches every reference that no longer lines up.

The draft flag and scheduled publishing

The draft field alone isn’t the full picture. The blog uses a small utility in src/utils/drafts.ts that combines draft status, publish date, and an environment variable:

src/utils/drafts.ts
export const showDrafts: boolean = import.meta.env.SHOW_DRAFTS === "true";
export function isPublished(post: { data: { draft: boolean; pubDate: Date } }): boolean {
if (showDrafts) return true;
if (post.data.draft) return false;
return post.data.pubDate <= new Date();
}

Every collection query that feeds the public site passes posts through isPublished() rather than a bare draft check:

import { getCollection } from "astro:content";
import { isPublished } from "../utils/drafts";
const allPosts = (await getCollection("posts")).filter(isPublished);

This gives three distinct states:

  • draft: true — never visible on the public site, regardless of pubDate.
  • draft: false, future pubDate — not yet published; filtered out until the date passes.
  • draft: false, past pubDate — live.

The SHOW_DRAFTS=true environment variable short-circuits the filter entirely, which is how the preview branch deploy works — it shows everything, including drafts and scheduled posts, behind a passcode wall.

Posts filtered out by isPublished() are excluded from listings, tag pages, the RSS feed, and getStaticPaths() — so their URLs return a 404 in production even if someone guesses the slug.

Optional fields and TypeScript

The cover, history, and credits fields are all .optional(). In templates this means you get types like ({ image: ImageMetadata; alt: string } | undefined). TypeScript will refuse to let you access cover.image.src without first checking that cover exists.

That’s the intended behaviour — it forces every template that uses these fields to handle the case where they’re absent, which is exactly the kind of bug that slips through without a type system.

What this gives you

Build-time validation. A malformed date, a missing required field, or a cover image pointing to a nonexistent file stops the build immediately. You find content errors locally, not after deploying.

Full TypeScript coverage across templates. Every .astro component that touches post.data gets accurate types. Rename a field in the schema and TypeScript surfaces every broken reference.

Scheduled publishing without a CMS. Because pubDate is a typed Date and the isPublished() filter compares it to the current time, setting a future date is enough to schedule a post. The daily build picks it up automatically. There’s a dedicated post on how this works coming 8 May.

Drafts with a preview workflow. draft: true keeps work-in-progress content off the live site, while the SHOW_DRAFTS flag lets you review it fully rendered on a separate deploy before it goes public.

Working on something similar?

If you’re setting up a content pipeline, designing a typed content model, or building publish workflows into your Astro site — I’m available for consulting.

Get in touch via the contact page and tell me what you’re working on.

Free · No spam · Unsubscribe any time

Get new posts in your inbox

When the next article drops, I'll send a short note — a link and a summary, nothing else. One email per post.

Did you find this useful?

Comments

Loading comments…

Leave a comment