Writing
Typed Content Collections in Astro
Astro 7 content layer with glob loaders, Zod schemas, MDX rendering, and static paths—using patterns from 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.