Skip to content
C Codeloom
Astro

Astro Content Collections: Type-Safe Markdown

A practical guide to Astro content collections — defining a zod schema in src/content/config.ts, using the glob loader, fetching entries with getCollection, and building a dynamic [...slug].astro route.

·8 min read · By Yash Kesharwani
Intermediate 12 min read

What you'll learn

  • What content collections are and why they replace ad-hoc Markdown imports
  • How to define a collection with defineCollection and a zod schema
  • How the glob loader maps files in src/content/ to entries
  • How to fetch and sort entries with getCollection
  • How to build a dynamic [...slug].astro route for individual posts

Prerequisites

Content collections are Astro’s first-class system for managing Markdown and MDX. Instead of importing files by hand and hoping the frontmatter has the right fields, you declare a schema once and Astro validates every file against it — with full TypeScript autocomplete everywhere you use the data.

This post covers how they work and walks through a complete blog setup.

The problem collections solve

The naive way to build a blog in Astro is to glob Markdown files yourself:

const posts = await import.meta.glob('./posts/*.md');

This works, but you get no type safety. A post with a typo in publishedAt, a missing title, or a tags field that should be an array but is a string slips through silently. You discover it as undefined in the template, or worse, in production.

Content collections fix that by introducing a schema. Every Markdown or MDX file is parsed and validated at build time. A missing field or a wrong type is a hard error before the site is built.

Step 1: The content directory

By convention, collections live under src/content/. Each subfolder is one collection. For a blog:

src/
└── content/
    ├── config.ts
    └── blog/
        ├── hello-world.md
        ├── astro-tips.mdx
        └── advanced-routing.mdx

The folder name blog becomes the collection name. The files inside are entries. Slugs are derived from filenames by default — hello-world.md becomes the slug hello-world.

Step 2: Define the collection

Create src/content/config.ts:

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
  schema: z.object({
    title: z.string().max(80),
    description: z.string().min(50).max(220),
    publishedAt: z.date(),
    category: z.string(),
    tags: z.array(z.string()).default([]),
    order: z.number().int().optional(),
    sidebarLabel: z.string().optional(),
  }),
});

export const collections = { blog };

Three pieces are doing work here.

1. The loader. Astro 5 uses the content layer with explicit loaders. glob walks a directory and treats each file as an entry. The pattern accepts .md and .mdx.

2. The schema. A zod object describing what every frontmatter must look like. Optional fields use .optional(). Defaults use .default(...). zod is the same library used by tRPC and many others — its docs are excellent.

3. The collections export. The keys are collection names. You can have several:

export const collections = { blog, docs, projects };

Once this file exists and the dev server has restarted at least once, Astro generates types for you. Everywhere you fetch entries, autocomplete shows your fields.

Step 3: Write some entries

A Markdown entry’s frontmatter must match the schema. Create src/content/blog/hello-world.md:

---
title: "Hello, world"
description: "The first post on this brand new Astro blog — a short introduction to what is coming."
publishedAt: 2026-06-16
category: "Meta"
tags: ["intro"]
---

Welcome to the blog. The body of the post is plain Markdown.

## A heading

Some text. **Bold**, _italic_, and `code` all work.

If you make a typo — say publishedAt: "not-a-date" — the build fails with a clear message pointing at the file and field. That is the whole point.

MDX files work the same way, with the added power of importing components into the body:

---
title: "An MDX post"
description: "Demonstrating that MDX entries can render components alongside the Markdown body."
publishedAt: 2026-06-17
category: "Tutorial"
tags: ["mdx"]
---

import Callout from '@/components/Callout.astro';

<Callout type="info">MDX can render components inline.</Callout>

Regular Markdown still works around them.

Step 4: Fetch entries with getCollection

To render a list of posts, ask Astro for the whole collection:

---
import { getCollection } from 'astro:content';

const posts = await getCollection('blog');

const sorted = posts.sort(
  (a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf()
);
---
<ul>
  {sorted.map((post) => (
    <li>
      <a href={`/blog/${post.id}`}>{post.data.title}</a>
      <p>{post.data.description}</p>
    </li>
  ))}
</ul>

A few things to notice:

  • getCollection('blog') returns an array of typed entries
  • Frontmatter lives on post.data — fully typed against your zod schema
  • post.id is the slug derived from the filename
  • Dates from the schema are real Date objects, not strings

You can also filter at fetch time:

const published = await getCollection('blog', ({ data }) => {
  return data.publishedAt <= new Date();
});

That excludes drafts dated in the future without ever rendering them.

Try it yourself. Add a draft: z.boolean().default(false) field to the schema. Mark one post as draft: true. Filter it out in getCollection. Confirm it no longer appears on the index page.

Step 5: A dynamic [...slug].astro route

The blog index lists posts. Each post needs its own page. Astro’s dynamic routes handle this with a single file: src/pages/blog/[...slug].astro.

---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---
<BaseLayout title={post.data.title} description={post.data.description}>
  <article>
    <header>
      <h1>{post.data.title}</h1>
      <time datetime={post.data.publishedAt.toISOString()}>
        {post.data.publishedAt.toDateString()}
      </time>
    </header>
    <Content />
  </article>
</BaseLayout>

The moving parts:

  • [...slug].astro is a rest-parameter route — it matches any path under /blog/
  • getStaticPaths tells Astro which slugs exist at build time. One page is generated per entry.
  • render(post) returns a <Content /> component that renders the Markdown or MDX body
  • The frontmatter is on post.data — exactly as it was in the index page

Restart the dev server. Visit /blog/hello-world. The post is there, rendered to HTML, with no JavaScript shipped.

Step 6: Tag and category pages

The same pattern produces tag and category pages. Create src/pages/blog/tag/[tag].astro:

---
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  const allTags = [...new Set(posts.flatMap((p) => p.data.tags))];

  return allTags.map((tag) => ({
    params: { tag },
    props: {
      tag,
      posts: posts.filter((p) => p.data.tags.includes(tag)),
    },
  }));
}

const { tag, posts } = Astro.props;
---
<BaseLayout title={`Tag: ${tag}`}>
  <h1>Posts tagged "{tag}"</h1>
  <ul>
    {posts.map((post) => (
      <li><a href={`/blog/${post.id}`}>{post.data.title}</a></li>
    ))}
  </ul>
</BaseLayout>

Astro builds one page per unique tag. No JavaScript ships to the visitor. Switching to a category page is a one-line change — swap tags for category.

Common schema patterns

A few zod patterns that come up constantly.

// Image references resolved by Astro
image: z.string().optional(),

// Enums for fixed sets
level: z.enum(['Beginner', 'Intermediate', 'Advanced']),

// Array with sensible defaults
tags: z.array(z.string()).default([]),

// Date stored as ISO string, parsed to Date
publishedAt: z.coerce.date(),

// Optional URL
canonicalUrl: z.string().url().optional(),

// References to other collections
relatedPosts: z.array(z.string()).optional(),

z.coerce.date() is particularly handy — it accepts 2026-06-16 as a plain string and gives you back a Date object.

Watching the schema in action

The best way to internalise the value of collections is to make a deliberate mistake.

  1. Remove the description from one of your Markdown files
  2. Run npm run dev

Astro prints a clean error: the file, the field, the rule that failed. The build refuses to proceed. Now imagine catching that bug six months later in production instead — that is what collections save you from.

Try it yourself. Add a cover field of type z.string().optional(). Render it as an <img> on the post page only if it is set. Then add cover to one post and leave it off another. Confirm the conditional render works.

Things that commonly trip people up

  • Edited config.ts but the types are stale — restart the dev server. The generator runs on startup.
  • getCollection returns [] — your loader pattern does not match. Double-check the base path.
  • Slug is wrong — Astro 5 uses post.id for slugs, not post.slug. The older API still appears in old tutorials.
  • Content is undefined — you forgot to await render(post). It is asynchronous in Astro 5.
  • MDX components do not render — confirm you added the @astrojs/mdx integration to astro.config.mjs.

Recap

You now know:

  • A collection is a folder under src/content/ with a schema in src/content/config.ts
  • defineCollection plus a zod schema gives you build-time validation and editor autocomplete
  • The glob loader maps files in a directory to entries
  • getCollection('name') fetches typed entries; post.data holds the frontmatter
  • A single [...slug].astro file with getStaticPaths generates one page per entry
  • The same pattern handles tag and category pages

Next steps

So far the entire site is static HTML. The next post adds the missing piece — islands — letting you embed React, Vue, or Svelte components into otherwise-static pages and hydrate them only when needed.

→ Next: Astro Islands

Questions or feedback? Email codeloomdevv@gmail.com.