Typed Content Collections in Astro 7
Glob loaders, Zod schemas, MDX rendering, and static paths — patterns used on this site.
Astro’s content layer validates frontmatter at build time, types every field in TypeScript, and renders Markdown or MDX without manual remark/rehype wiring. Invalid content fails the build instead of shipping broken pages.
This article documents the patterns used on this site (Astro 7, @astrojs/mdx 7). For current API details, see the Astro content collections guide.
Loader and schema
Collections are defined in src/content.config.ts. Astro 5+ uses the content layer API with explicit loaders instead of the older type: 'content' convention.
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
publishDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
description: z.string(),
draft: z.boolean().default(false),
heroImage: image().optional(),
}),
});
export const collections = { blog };
Key points:
globloader — Matches.mdand.mdxfiles undersrc/content/blog. The[^_]*pattern excludes partial files prefixed with_.z.coerce.date()— Parses ISO date strings from frontmatter intoDateobjects.image()— References local images; Astro optimizes them at build time viaastro:assets.draft: true— Excluded from production queries when you filter on!data.draft.
Run astro sync or start the dev server to regenerate types in .astro/types.d.ts.
Querying collections
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(),
);
getCollection returns typed entries. entry.data.title, entry.data.tags, and other schema fields are autocompleted in TypeScript.
Entry id is the filename without extension—s-corp-vs-llc for s-corp-vs-llc.mdx. Use it for URLs and static paths.
Rendering with render()
import { render } from 'astro:content';
const { Content, headings } = await render(entry);
Content is a component for the body. headings provides slug, depth, and text for building a table of contents—used in this site’s blog layout:
---
const { Content, headings } = await render(entry);
---
<div class="prose">
<Content />
</div>
<nav>
{headings
.filter((h) => h.depth === 2)
.map((h) => <a href={`#${h.slug}`}>{h.text}</a>)}
</nav>
No manual MDX compiler setup required for basic rendering.
Static paths
export async function getStaticPaths() {
const entries = await getCollection('blog', ({ data }) => !data.draft);
return entries.map((entry) => ({
params: { slug: entry.id },
props: { entry },
}));
}
Each entry becomes a page at /blog/[slug]/. Use withBase() when generating links if base is not /:
import { withBase } from '../lib/url';
link: withBase(`/blog/${post.id}/`),
MDX components
MDX files can import Astro components directly:
import Callout from '../../components/blog/Callout.astro';
import ComparisonTable from '../../components/blog/ComparisonTable.astro';
<Callout type="warning" title="Build-time validation">
Invalid frontmatter fails `astro build`, not the browser.
</Callout>
Components live in src/components/blog/ with styles in global.css under .blog-callout, .blog-table, and .blog-checklist. Keep components focused—callouts, tables, and checklists cover most editorial needs without a full design system.
RSS and sitemap from collections
This site’s RSS endpoint queries the same collection:
// src/pages/rss.xml.ts
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf());
return rss({
title: 'Andrew Herendeen',
site: context.site ?? 'https://aherendeen.com',
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.publishDate,
link: withBase(`/blog/${post.id}/`),
categories: post.data.tags,
})),
});
@astrojs/sitemap in astro.config.mjs generates sitemap entries from built pages automatically.
Schema design principles
Keep frontmatter small:
| Field | Purpose |
|---|---|
title |
Page heading and RSS title |
description |
Meta description and RSS summary |
publishDate |
Sort order and display date |
tags |
Filtering and RSS categories |
draft |
Exclude from production |
heroImage |
Optional optimized header image |
Put long structured data in the body, not frontmatter. Adding a schema field updates every consumer at compile time—which is the point.
When collections are worth it
Collections pay off when you have:
- Multiple content types (blog, works, docs)
- RSS or sitemap generation from content
- Tag or category index pages
- CI that must fail on malformed drafts
- MDX components shared across articles
For a single static page, a .astro file is simpler. For a writing section with eight articles and an RSS feed, collections are the right tool.
Closing perspective
Typed content collections turn your Markdown into a validated data source. The upfront cost is defining a schema; the payoff is build-time safety, TypeScript autocomplete, and a single query pattern for pages, RSS, and indexes. This site’s entire blog runs on one collection, one schema, and three MDX components.