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.
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
- •Comfort with Astro components — see Astro Components and Layouts
- •Basic TypeScript and a passing familiarity with zod
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.idis the slug derived from the filename- Dates from the schema are real
Dateobjects, 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].astrois a rest-parameter route — it matches any path under/blog/getStaticPathstells 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.
- Remove the
descriptionfrom one of your Markdown files - 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.tsbut the types are stale — restart the dev server. The generator runs on startup. getCollectionreturns[]— yourloaderpattern does not match. Double-check thebasepath.- Slug is wrong — Astro 5 uses
post.idfor slugs, notpost.slug. The older API still appears in old tutorials. Contentis undefined — you forgot toawait render(post). It is asynchronous in Astro 5.- MDX components do not render — confirm you added the
@astrojs/mdxintegration toastro.config.mjs.
Recap
You now know:
- A collection is a folder under
src/content/with a schema insrc/content/config.ts defineCollectionplus a zod schema gives you build-time validation and editor autocomplete- The
globloader maps files in a directory to entries getCollection('name')fetches typed entries;post.dataholds the frontmatter- A single
[...slug].astrofile withgetStaticPathsgenerates 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.
Questions or feedback? Email codeloomdevv@gmail.com.