DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on • Originally published at sourcier.uk

Typed content collections in Astro

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:

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 };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)