Typed content collections in Astro
Zod schemas, build-time validation, and what the draft flag actually does
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:
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 like2026-03-26T00:00:00is automatically coerced to a JavaScriptDateobject. 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 processedsrc. Astro passes this through its image optimisation pipeline.draft: z.boolean().default(false)means thedraftfield is optional in frontmatter — omitting it defaults tofalse, so only posts that explicitly declaredraft: trueneed the field.creditsURLs usez.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.jpgCo-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:
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 ofpubDate.draft: false, futurepubDate— not yet published; filtered out until the date passes.draft: false, pastpubDate— 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.
Support this blog
If you found this useful, a small tip keeps the writing going.
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