One-off tip

Support checkout

  1. 1 Choose amount
  2. 2 Payment
  3. 3 Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Typed content collections in Astro
Cover image: Wooden bookcase filled with books — Photo by Jason Leung on Unsplash
astro engineering frontend

Typed content collections in Astro

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

Published
1 April 2026
Read time
5 min read
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?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.