Content and Collections
This is a member-only chapter. Log in with your Signal Over Noise membership email to continue.
Log in to readModule 4: Content and Collections
Astro’s content system is where a lot of the real-world value shows up. If you’re building anything with recurring content — a blog, a course, a documentation site, a portfolio — content collections give you a structured way to manage it that plays well with version control, scales gracefully, and makes Claude Code genuinely useful for content creation.
Why Not Just Use Pages?
You could put every blog post in src/pages/blog/post-name.astro. It works. But you end up with component boilerplate in every content file, no way to query posts by date or tag, no type safety on frontmatter, and no central place to define what a post is.
Content collections fix all of that. You write content in Markdown, define a schema once, and Astro handles querying and type-checking. The content files stay clean — just words and frontmatter, no component code.
Setting Up Your First Collection
Create the directory structure:
src/
└── content/
├── config.ts
└── blog/
└── first-post.md
The config.ts file defines your collection schema using Zod (Astro bundles it):
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
This schema enforces that every post has a title, description, and publication date. If frontmatter is missing or wrong, the build fails with a clear error rather than silently serving broken content.
Writing a Post
---
title: "My First Post"
description: "A short introduction."
pubDate: 2025-03-01
tags: ["writing", "web"]
---
Your content goes here. Plain Markdown, no component syntax required.
That’s it. The frontmatter matches the schema, and the rest is content.
Creating the Blog Index Page
Create src/pages/blog/index.astro to list all posts:
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title="Blog">
<main class="container">
<h1>Blog</h1>
<ul class="post-list">
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric'
})}
</time>
<p>{post.data.description}</p>
</li>
))}
</ul>
</main>
</BaseLayout>
getCollection returns all entries that match the filter — here, anything that isn’t a draft. The posts are then sorted newest first.
Dynamic Routes for Individual Posts
Astro needs to know which pages to generate at build time. For dynamic content, you use getStaticPaths:
Create src/pages/blog/[slug].astro:
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article class="container">
<h1>{post.data.title}</h1>
<Content />
</article>
</BaseLayout>
getStaticPaths runs at build time and tells Astro every URL this page needs to generate. For a collection of ten posts, it generates ten pages.
The sbc.jimchristian.net Pattern
The Second Brain Chronicles blog uses this exact structure. Posts live in src/content/blog/, each with frontmatter for title, description, publication date, tags, and a hero image path. The schema validates all of it. The index page sorts and filters. Individual pages render with the same layout.
The result: adding a new post means creating a Markdown file. Nothing else changes. The index updates automatically at the next build.
How Claude Code Works With Collections
This is where structuring content well pays off. Once Claude Code knows your schema and the Markdown conventions, you can ask it to:
- Draft a new post: “Write a 600-word post on [topic] following the frontmatter schema in src/content/config.ts”
- Generate frontmatter: “Create the frontmatter for a post about [topic], pubDate today, three relevant tags”
- List missing fields: “Read the last five posts in src/content/blog/ and flag any frontmatter that doesn’t match the schema”
- Create a series: “Create three related posts as a series on [topic], linking to each other in the body”
The typed schema is the contract. When Claude Code respects it — and it will, if you tell it what it is — the content stays consistent across every post, regardless of when it was written or what session generated it.
Extending to Other Collection Types
Blog posts aren’t the only thing collections handle well. This platform uses a similar pattern for chapter content — each chapter is a Markdown file with frontmatter that defines title, chapter number, access level, and product slug.
You can create as many collections as you need:
export const collections = {
blog,
projects,
guides,
courses,
};
Each collection gets its own schema. Each schema enforces its own frontmatter requirements.
You now have a content system that scales. Module 5 covers getting it live.
Check Your Understanding
Answer all questions correctly to complete this module.
1. What advantage do Astro content collections have over .astro files in src/pages/blog/?
2. What happens if a collection entry's frontmatter doesn't match the schema?
3. What does getStaticPaths do?
Pass the quiz above to unlock
Save failed. Please try again.