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:

  • glob loader — Matches .md and .mdx files under src/content/blog. The [^_]* pattern excludes partial files prefixed with _.
  • z.coerce.date() — Parses ISO date strings from frontmatter into Date objects.
  • image() — References local images; Astro optimizes them at build time via astro: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.