Building a Type-Safe Blog with Astro 5
Astro 5 shipped with one of the most practical features I’ve seen in a meta-framework: the Content Layer API. It replaces the old content collections with a more flexible, loader-based approach — and it’s fully type-safe out of the box.
Defining a collection
Content collections start with a schema. You define what your frontmatter looks like using Zod, and Astro validates every file at build time:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
date: z.date(),
description: z.string(),
category: z.enum(['devops', 'web', 'ai']),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog };
The glob loader scans for Markdown files and generates typed entries. If a post has a missing title or an invalid category, you’ll get a build error — not a broken page in production.
Querying content
Fetching posts is straightforward:
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
// Full type inference — post.data.title is string, post.data.date is Date
const published = posts
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
No need for as casts or manual type assertions. The types flow directly from your Zod schema.
Rendering posts
Rendering has moved from entry.render() to a standalone function:
import { render } from 'astro:content';
const { Content } = await render(post);
Then in your template:
<div class="prose">
<Content />
</div>
Multi-language content
One pattern I’ve been using is organizing translations as sibling files inside a folder:
src/content/blog/
├── my-first-post/
│ ├── en.md
│ └── de.md
└── another-post/
├── en.md
└── de.md
The glob loader gives each entry an id like my-first-post/en, which you can parse to extract the slug and language:
function parsePostId(id: string) {
const lastSlash = id.lastIndexOf('/');
return {
slug: id.slice(0, lastSlash),
lang: id.slice(lastSlash + 1),
};
}
Filtering posts by language then becomes a one-liner:
const enPosts = posts.filter((p) => parsePostId(p.id).lang === 'en');
Why this matters
The old Astro content collections worked, but they had rough edges around typing and required specific directory conventions. The Content Layer API is more explicit — you choose the loader, you define the schema, and the types follow naturally.
Combined with Astro’s built-in Shiki syntax highlighting and static output, you get a blog that’s fast, type-safe, and easy to maintain. No runtime, no JavaScript shipped to the client for content pages. Just HTML.