Type-Safe Content in Astro: Moving Beyond Glob Imports with Content Collections
Astro Content Collections replace fragile glob imports with Zod-validated, TypeScript-typed content authoring. This post walks through migrating a blog from Astro.glob() to collections, shows generated types, and covers trade-offs: dev startup cost, render() constraints, and image handling.
04 Jun 2026, 08:30 UTC

The Problem: Runtime Surprises from Markdown Front-Matter
If you've built an Astro site with Markdown or MDX, you've likely used Astro.glob() to load files. It works—until a missing date field or a typo in tags crashes your build at runtime, or worse, renders undefined silently in production. The feedback loop is slow: write front-matter, run the dev server, hit the page, discover the error.
Astro Content Collections, stabilized in Astro 3.0 and extended by the Content Layer in 4.0, replace that ad-hoc approach with a structured src/content/ directory, Zod schemas validated at dev/build time, and generated TypeScript types. The result: front-matter errors surface in your editor and terminal before the browser ever loads.
How Collections Work
Each collection lives under src/content/<name>/ (e.g., src/content/blog/). A config.ts at src/content/config.ts defines a Zod schema per collection:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content', // Markdown/MDX
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
Astro generates src/content.d.ts (or .astro/types.d.ts) with interfaces matching your schema. getCollection('blog') and getEntry('blog', 'slug') return fully typed entries:
data— validated front-matter (dates asDateobjects, arrays asstring[])body— raw Markdown stringrender()— async function returning an MDX component factory
Invalid front-matter throws a clear error with file path and Zod issue details during npm run dev or npm run build.
Worked Example: Migrating a Blog from Astro.glob()
Before: Glob Imports with Manual Parsing
// src/pages/blog/index.astro (legacy)
---
const posts = await Astro.glob('../content/blog/*.md');
---
{posts.map(post => (
<li><a href={post.url}>{post.frontmatter.title}</a></li>
))}
No type safety. post.frontmatter.tags might be a string, an array, or missing entirely.
After: Collection-Powered List Page
- Move files to
src/content/blog/(keep folder structure; nested dirs become part of the slug). - Add
src/content/config.tswith the schema above. - Update the list page:
// src/pages/blog/index.astro
---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
{posts
.filter(p => !p.data.draft)
.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf())
.map(post => (
<li>
<a href={/blog/${post.slug}/}>{post.data.title}</a>
<time>{post.data.publishDate.toDateString()}</time>
<ul>{post.data.tags.map(t => <li>{t}</li>)}</ul>
</li>
))}
post.data.publishDate is a Date; post.data.tags is string[]. TypeScript flags any misuse immediately.
Dynamic Route: [slug].astro
// src/pages/blog/[slug].astro
---
import { getCollection, getEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.filter(p => !p.data.draft).map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
{post.data.title}
{post.data.publishDate.toDateString()}
post.render() returns { Content: AstroComponentFactory }; you render it with <Content /> inside an .astro file.
Trade-Offs and Limitations
| Area | Constraint | Mitigation |
|---|---|---|
| Dev startup time | Astro validates every file in src/content/ on server start. 10k+ entries can add seconds. |
Use type: 'data' with JSON for high-volume structured data; enable incremental builds. |
render() in framework components |
The MDX component factory only works inside .astro files. |
Wrap the <Content /> render in an Astro component, then import that wrapper into React/Vue/Svelte. |
| Image optimization | Front-matter image paths are not auto-processed by Astro's image service. | Use the image() schema helper (z.object({ hero: z.image() })) or store optimized assets in public/ and reference by path. |
| Remote loaders (Content Layer) | Zod schemas must be serializable; custom refinements using Node APIs break remote loaders. | Keep schemas pure—coerce/transform with Zod's built-ins (z.coerce.date(), z.string().transform(...)). |
| Collection naming | Directory names must be valid JS identifiers; kebab-case becomes camelCase (blog-posts → getCollection('blogPosts')). |
Name directories accordingly or accept the conversion. |
Verification Checklist
After adding a collection, confirm the migration works:
- Run
npm run devand introduce an invalid front-matter value (e.g.,publishDate: "not-a-date"). The terminal should show a Zod error with the file path. - Open
src/content.d.ts(or.astro/types.d.ts) and verify the generated interface matches your schema, includingrender(): Promise<{ Content: AstroComponentFactory }>. - In an
.astropage, map overgetCollection('blog')and accessentry.data.title—TypeScript should autocomplete the fields. - For a
type: 'data'collection (JSON/YAML), confirmgetEntryreturns parsed, validated data without arender()method.
Next Steps
Start small: pick one content-heavy section (blog, docs, changelog), move its files to src/content/, write a minimal Zod schema, and switch the listing and detail pages to getCollection/getEntry. Fix the type errors that appear—they're the bugs you would have shipped. Once comfortable, explore the Content Layer to pull the same typed entries from a headless CMS without changing your page components.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.