Your Markdown frontmatter is a liability. One missing field, one wrong date format, and your build fails silently—or worse, deploys broken pages to production. I’ve shipped dozens of content-heavy Astro sites, and content collections have eliminated entire categories of bugs from my workflow.
Astro content collections are the framework’s built-in system for managing structured content with TypeScript type safety and Zod schema validation. With Astro 5.0’s Content Layer API, you can now load content from anywhere—local files, remote APIs, or headless CMS platforms—while enjoying 5x faster builds and 50% less memory usage than the legacy approach.
This guide covers everything from basic setup to production patterns, updated for Astro 5.0+.
TL;DR — 5 Things You Need to Know
- Config file location changed — Use
src/content.config.ts(notsrc/content/config.ts) in Astro 5.0+ - Loaders replace type declarations — Use
loader: glob()for files,loader: file()for JSON/YAML data - Performance is dramatically better — 5x faster Markdown builds, 2x faster MDX, 25-50% less memory
- Remote content is now native — Custom loaders can fetch from any CMS, API, or database
- Run
npx astro sync— Always regenerate types after changing your schema
What Are Astro Content Collections?
Content collections are Astro’s built-in way to manage sets of related content—blog posts, documentation pages, product listings, author profiles—with automatic validation and type inference. They replace manual file imports with a structured, type-safe API.
Here’s what content collections provide:
| Feature | Without Collections | With Collections |
|---|---|---|
| File Loading | Manual import.meta.glob() | getCollection() API |
| Frontmatter Validation | None (runtime errors) | Zod schema (build-time errors) |
| TypeScript Support | No type inference | Full auto-generated types |
| Error Detection | Production/runtime | Development/build time |
| Content Caching | Manual implementation | Built-in optimization |
| Remote Content | Custom fetch logic | Pluggable loaders |
The Content Layer API (Astro 5.0+)
Astro 5.0 introduced the Content Layer API, a complete rewrite of how collections work under the hood. For the official documentation, see the Astro Content Collections Guide. The key changes:
- Loaders replace type declarations — Instead of
type: "content", you useloader: glob()orloader: file() - Content lives anywhere — No longer restricted to
src/content/; store files anywhere on your filesystem - Remote content support — Custom loaders can fetch from any API, CMS, or database
- Massive performance gains — Up to 5x faster Markdown builds, 2x faster MDX, 25-50% less memory

Content Layer architecture: Sources → Loaders → Cached Data Store → Static Output
Migrating from Astro 2-4? The config file location changed from
src/content/config.tstosrc/content.config.ts. See the migration section below.
Setting Up Your First Content Collection
Let’s build a blog collection from scratch using the current Astro 5.0+ API.
Step 1: Create the Content Config
Create src/content.config.ts (note: this is in src/, not src/content/):
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
// Load all .md and .mdx files from the blog directory
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
schema: z.object({
title: z.string().min(10).max(100),
description: z.string().max(160), // SEO-optimized length
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default("Anonymous"),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
What’s happening here:
glob()loads files matching the pattern from the specified base directoryz.coerce.date()converts date strings (like2024-11-09) to JavaScript Date objects.default()provides fallback values for optional fields.max(160)on description enforces SEO-friendly meta description length
Step 2: Create Your Content Directory
Create the directory structure for your blog posts:
src/
├── content.config.ts # Collection definitions
└── content/
└── blog/
├── my-first-post.mdx
├── astro-tips.md
└── tutorials/
└── advanced-routing.mdx
The glob() loader with **/*.{md,mdx} pattern will find files in subdirectories too, so you can organize content however you like. MDX files let you embed interactive UI components directly in your content.
Step 3: Write Your First Content File
Create src/content/blog/my-first-post.mdx:
---
title: "Building My First Astro Site"
description: "A walkthrough of creating a blazing-fast static site with Astro"
pubDate: 2024-11-09
author: "Your Name"
heroImage: "/images/first-post.jpg"
tags: ["astro", "tutorial", "web-development"]
---
Welcome to my blog! This post was validated by Zod at build time.
## Why Astro?
Astro ships zero JavaScript by default, making it perfect for content sites...
If any required field is missing or has the wrong type, Astro shows a clear error during astro dev or astro build—not in production.
Step 4: Query Content in Your Pages
Create a blog index at src/pages/blog/index.astro:
---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";
// Fetch all blog posts, filtering out drafts in production
const posts = await getCollection("blog", ({ data }) => {
return import.meta.env.PROD ? !data.draft : true;
});
// Sort by publication date (newest first)
const sortedPosts = posts.sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
---
<Layout title="Blog">
<h1>Latest Posts</h1>
<ul role="list">
{
sortedPosts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</a>
</li>
))
}
</ul>
</Layout>
Notice post.data is fully typed—your editor knows exactly what fields exist and their types. This type safety is similar to what you get with React Hooks when using TypeScript.
Step 5: Create Dynamic Post Pages
For individual posts, create src/pages/blog/[...slug].astro:
---
import { getCollection, render } from "astro:content";
import Layout from "../../layouts/Layout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
---
<Layout title={post.data.title} description={post.data.description}>
<article>
<header>
<h1>{post.data.title}</h1>
<p class="description">{post.data.description}</p>
{
post.data.heroImage && (
<img
src={post.data.heroImage}
alt=""
width="1200"
height="630"
loading="eager"
/>
)
}
<div class="meta">
<span>By {post.data.author}</span>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
</div>
</header>
<Content />
{
post.data.tags.length > 0 && (
<footer>
<p>Tags: {post.data.tags.join(", ")}</p>
</footer>
)
}
</article>
</Layout>
The render() function returns a Content component for your Markdown/MDX and a headings array for building table of contents.
Understanding Loaders: glob() vs file()
Astro 5.0 provides two built-in loaders for local content:
The glob() Loader
Creates one entry per file. Best for content that renders as individual pages.
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({
pattern: "**/*.md", // Glob pattern for files
base: "./src/content/blog", // Base directory
}),
schema: z.object({
/* ... */
}),
});
Use glob() for: Blog posts, documentation pages, portfolio projects, case studies.
The file() Loader
Creates multiple entries from a single file. Best for structured data.
import { file } from "astro/loaders";
const authors = defineCollection({
loader: file("src/data/authors.json"),
schema: z.object({
id: z.string(),
name: z.string(),
bio: z.string(),
avatar: z.string(),
twitter: z.string().optional(),
}),
});
Your authors.json would look like:
[
{
"id": "jane-doe",
"name": "Jane Doe",
"bio": "Frontend developer and technical writer",
"avatar": "/images/authors/jane.jpg",
"twitter": "@janedoe"
},
{
"id": "john-smith",
"name": "John Smith",
"bio": "Full-stack engineer specializing in Astro",
"avatar": "/images/authors/john.jpg"
}
]
Use file() for: Author profiles, navigation menus, site configuration, product catalogs, team members.
Advanced Schema Patterns with Zod
Zod is the validation library powering content collections. Here are production-ready patterns. If you’re coming from a React and TypeScript background, our guide on TypeScript patterns for React developers covers many of the same type-safety concepts that Zod builds on.
Cross-Collection References
Link entries between collections using reference():
import { defineCollection, reference, z } from "astro:content";
import { glob, file } from "astro/loaders";
const authors = defineCollection({
loader: file("src/data/authors.json"),
schema: z.object({
id: z.string(),
name: z.string(),
bio: z.string(),
}),
});
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
author: reference("authors"), // Single reference
relatedPosts: z.array(reference("blog")).optional(), // Array of references
}),
});
export const collections = { authors, blog };
In your frontmatter, reference by ID:
---
title: "Understanding Astro Islands"
author: jane-doe
relatedPosts:
- my-first-post
- astro-tips
---
Query the referenced data in your component:
---
import { getEntry } from "astro:content";
const post = await getEntry("blog", "understanding-astro-islands");
const author = await getEntry(post.data.author);
---
<p>Written by {author.data.name}</p>
Flexible Date Handling
Handle dates from different sources (CMS calendars vs manual entry):
schema: z.object({
pubDate: z.union([
z.coerce.date(), // Handles "2024-11-09" strings
z.date(), // Handles Date objects from CMS
z.string().transform((s) => new Date(s)), // Custom parsing
]),
});
Discriminated Unions for Content Types
Different content types with different required fields:
const content = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content" }),
schema: z.discriminatedUnion("type", [
z.object({
type: z.literal("article"),
title: z.string(),
author: z.string(),
readingTime: z.number(),
}),
z.object({
type: z.literal("video"),
title: z.string(),
videoUrl: z.string().url(),
duration: z.string(),
}),
z.object({
type: z.literal("podcast"),
title: z.string(),
audioUrl: z.string().url(),
guests: z.array(z.string()),
}),
]),
});
Image Validation
Validate images with dimension requirements:
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: ({ image }) =>
z.object({
title: z.string(),
cover: image().refine((img) => img.width >= 1200, {
message: "Cover image must be at least 1200px wide",
}),
thumbnail: image().optional(),
}),
});
Loading Remote Content with Custom Loaders
The Content Layer API’s killer feature: load content from any source.
Basic Inline Loader
For simple API calls, use an inline async function:
const countries = defineCollection({
loader: async () => {
const response = await fetch("https://restcountries.com/v3.1/all");
const data = await response.json();
// Must return array with id property on each entry
return data.map((country) => ({
id: country.cca3,
name: country.name.common,
capital: country.capital?.[0],
population: country.population,
region: country.region,
}));
},
schema: z.object({
id: z.string(),
name: z.string(),
capital: z.string().optional(),
population: z.number(),
region: z.string(),
}),
});
Advanced Loader with Caching
For production, build a loader object with incremental updates:
import type { Loader } from "astro/loaders";
export function cmsLoader(options: { apiUrl: string; token: string }): Loader {
return {
name: "cms-loader",
load: async ({ store, meta, logger }) => {
// Check for cached sync token
const lastSync = meta.get("lastSync");
const headers = {
Authorization: `Bearer ${options.token}`,
...(lastSync && { "If-Modified-Since": lastSync }),
};
const response = await fetch(options.apiUrl, { headers });
// Skip update if content hasn't changed
if (response.status === 304) {
logger.info("Content unchanged, using cache");
return;
}
const data = await response.json();
// Store sync token for next build
meta.set("lastSync", response.headers.get("Last-Modified"));
// Clear and repopulate store
store.clear();
for (const item of data.entries) {
store.set({
id: item.slug,
data: item,
});
}
logger.info(`Loaded ${data.entries.length} entries from CMS`);
},
};
}
Use it in your config:
import { cmsLoader } from "./loaders/cms";
const articles = defineCollection({
loader: cmsLoader({
apiUrl: "https://api.your-cms.com/articles",
token: import.meta.env.CMS_TOKEN,
}),
schema: z.object({
/* ... */
}),
});
Community Loaders
Popular CMS platforms already have official loaders:
- Storyblok:
@storyblok/astro - Contentful:
@contentful/astro - Sanity:
@sanity/astro - Notion:
notion-astro-loader - WordPress:
astro-wp-loader
import { storyblokLoader } from "@storyblok/astro";
const stories = defineCollection({
loader: storyblokLoader({
accessToken: import.meta.env.STORYBLOK_TOKEN,
version: "published",
}),
});
Astro vs. Next.js for Content Sites
Choosing between Astro and Next.js for content-heavy sites? Here’s a direct comparison. For a broader look at how Astro stacks up against React-based frameworks for different use cases, see our React vs Next.js vs Astro comparison.
| Aspect | Astro | Next.js |
|---|---|---|
| Default JavaScript | Zero JS shipped | React runtime required |
| Build Speed (1000 pages) | ~30 seconds | ~2-3 minutes |
| Content Type Safety | Built-in with Zod | Manual setup required |
| Learning Curve | Lower (HTML-first) | Higher (React knowledge needed) |
| Interactive Components | Islands architecture | Full hydration or RSC |
| Best For | Blogs, docs, marketing | Apps, dashboards, e-commerce |
| SSR Support | Yes (on-demand) | Yes (default) |
| Edge Runtime | Yes | Yes |
| Image Optimization | Built-in | Built-in |
| MDX Support | Native | Plugin required |
Choose Astro when:
- Content is the primary focus (blogs, documentation, portfolios)
- Performance is critical (Core Web Vitals matter for SEO)
- You want minimal client-side JavaScript
- Your team includes non-React developers
Choose Next.js when:
- Building highly interactive applications
- You need complex client-side state management
- Your team is already React-focused
- You’re building a full-stack application with API routes
For a deeper dive into React-based frameworks, see our React Server Components guide.
Performance Benchmarks
Real-world performance data comparing Astro 5.0 to alternatives:
| Metric | Astro 5.0 | Next.js 15 | Gatsby 5 |
|---|---|---|---|
| Build Time (500 MD files) | 12s | 45s | 38s |
| Build Time (500 MDX files) | 18s | 52s | 44s |
| Memory Usage (build) | 180MB | 450MB | 520MB |
| Lighthouse Score (typical) | 100 | 85-95 | 90-98 |
| JS Bundle (content page) | 0KB | 85KB+ | 45KB+ |
| Time to Interactive | under 1s | 2-3s | 1.5-2s |
Benchmarks from Astro team testing, March 2026. Results vary based on content complexity and plugins.
The Content Layer API specifically improved:
- Markdown builds: Up to 5x faster than Astro 4.x
- MDX builds: Up to 2x faster
- Memory usage: 25-50% reduction
- Incremental builds: Only changed files reprocessed
When to Use Content Collections (And When Not To)
Content collections aren’t always the right choice. Here’s a decision framework:
✅ Use Content Collections When:
| Scenario | Why Collections Work |
|---|---|
| Blog with any number of posts | Type safety, fast builds, zero runtime JS |
| Documentation site | Pairs perfectly with Starlight, supports versioning |
| Portfolio/case studies | Structured data with consistent fields |
| E-commerce catalog | Webhook-triggered rebuilds on product changes |
| Marketing pages | Content team edits, dev team deploys |
| Multi-language content | One collection per locale, shared schema |
For teams with design engineers, content collections provide the structured foundation that bridges design systems with content management.
❌ Avoid Content Collections When:
| Scenario | Better Alternative |
|---|---|
| Real-time data (stock prices, live scores) | On-demand rendering (SSR) |
| Personalized content per user | Server-side rendering with auth |
| Data changing every few minutes | API calls at request time |
| User-generated content | Database + SSR |
| A/B testing variants | Edge middleware or Server Islands |
🔄 Hybrid Approach: Server Islands
Combine static content collections with dynamic server-rendered sections. This pattern is similar to React Server Components, where you mix static and dynamic rendering:
---
import { getEntry } from "astro:content";
const post = await getEntry("blog", Astro.params.slug);
const { Content } = await render(post);
---
<Layout>
<!-- Static: rendered at build time from collection -->
<article>
<Content />
</article>
<!-- Dynamic: rendered on each request -->
<aside server:defer>
<RelatedPosts postId={post.id} />
<Comments postId={post.id} />
</aside>
</Layout>
This gives you the performance of static content with the flexibility of dynamic features.
Performance Optimization
Content collections are already optimized, but here’s how to maximize performance.
Filter Early, Not Late
Always filter in getCollection(), not after:
// ✅ Good: filters during fetch
const published = await getCollection("blog", ({ data }) => !data.draft);
// ❌ Avoid: fetches everything, then filters
const all = await getCollection("blog");
const published = all.filter((post) => !post.data.draft);
Use getEntry() for Single Items
When you only need one entry:
import { getEntry } from "astro:content";
// ✅ Efficient: fetches only what you need
const post = await getEntry("blog", "specific-post-id");
// ❌ Wasteful: fetches entire collection
const posts = await getCollection("blog");
const post = posts.find((p) => p.id === "specific-post-id");
Lazy Load Content Rendering
Only call render() when displaying full content:
// List page: just use metadata
const posts = await getCollection("blog");
posts.forEach((post) => {
console.log(post.data.title); // ✅ No rendering needed
});
// Single page: render content
const { Content } = await render(post); // Only here
Leverage Build Caching
The Content Layer caches parsed content between builds. To maximize cache hits:
- Don’t modify content files unnecessarily
- Use
generateDigest()in custom loaders for change detection - Store sync tokens in
metafor incremental API updates
Common Patterns and Recipes
Tag Pages with Dynamic Routes
Create src/pages/tags/[tag].astro:
---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
// Extract unique tags
const tags = [...new Set(posts.flatMap((post) => post.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: {
tag,
posts: posts.filter((post) => post.data.tags.includes(tag)),
},
}));
}
const { tag, posts } = Astro.props;
---
<Layout title={`Posts tagged "${tag}"`}>
<h1>#{tag}</h1>
<p>{posts.length} post{posts.length !== 1 ? "s" : ""}</p>
<ul>
{
posts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
</li>
))
}
</ul>
</Layout>
RSS Feed Generation
Create src/pages/rss.xml.ts:
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
export async function GET(context: APIContext) {
const posts = await getCollection("blog", ({ data }) => !data.draft);
const sortedPosts = posts.sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
return rss({
title: "My Blog",
description: "Articles about web development and Astro",
site: context.site!,
items: sortedPosts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.id}/`,
categories: post.data.tags,
})),
customData: `<language>en-us</language>`,
});
}
Sitemap with Content Collections
Astro’s @astrojs/sitemap integration automatically includes collection pages. For custom control:
// astro.config.mjs
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://example.com",
integrations: [
sitemap({
filter: (page) => !page.includes("/draft/"),
changefreq: "weekly",
priority: 0.7,
}),
],
});
Search Index Generation
Create src/pages/search.json.ts for client-side search:
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
export async function GET(context: APIContext) {
const posts = await getCollection("blog", ({ data }) => !data.draft);
const searchIndex = posts.map((post) => ({
id: post.id,
title: post.data.title,
description: post.data.description,
tags: post.data.tags,
url: `/blog/${post.id}/`,
}));
return new Response(JSON.stringify(searchIndex), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
},
});
}
Table of Contents from Headings
The render() function returns parsed headings:
---
import { getEntry, render } from "astro:content";
const post = await getEntry("blog", Astro.params.slug);
const { Content, headings } = await render(post);
---
<nav class="toc">
<h2>Table of Contents</h2>
<ul>
{
headings
.filter((h) => h.depth <= 3)
.map((heading) => (
<li style={`margin-left: ${(heading.depth - 1) * 1}rem`}>
<a href={`#${heading.slug}`}>{heading.text}</a>
</li>
))
}
</ul>
</nav>
<Content />
Migrating from Legacy Content Collections
If you’re upgrading from Astro 2.x-4.x, here’s the migration path.
Config File Location
- src/content/config.ts
+ src/content.config.ts
Schema Definition Changes
// Before (Astro 2-4)
const blog = defineCollection({
- type: "content",
schema: z.object({ /* ... */ }),
});
// After (Astro 5+)
+ import { glob } from "astro/loaders";
const blog = defineCollection({
+ loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({ /* ... */ }),
});
Data Collections Migration
// Before
const authors = defineCollection({
- type: "data",
schema: z.object({ /* ... */ }),
});
// After
+ import { file } from "astro/loaders";
const authors = defineCollection({
+ loader: file("src/content/authors/authors.json"),
schema: z.object({ /* ... */ }),
});
Slug vs ID
In the legacy API, entries had a slug property. In Astro 5+, use id:
// Before
- <a href={`/blog/${post.slug}`}>
// After
+ <a href={`/blog/${post.id}`}>
Regenerate Types
After migrating, regenerate TypeScript types:
npx astro sync
Common Migration Mistakes
Avoid these pitfalls when upgrading to Astro 5.0:
1. Wrong config file location
❌ src/content/config.ts (old location)
✅ src/content.config.ts (new location)
2. Forgetting to update imports
// ❌ Old import
import { z, defineCollection } from "astro:content";
// ✅ New import (add loaders)
import { z, defineCollection } from "astro:content";
import { glob, file } from "astro/loaders";
3. Using slug instead of id
// ❌ Old API
<a href={`/blog/${post.slug}`}>
// ✅ New API
<a href={`/blog/${post.id}`}></a></a
>
4. Not running astro sync after schema changes
Always run npx astro sync after modifying your schema. TypeScript errors about missing properties usually mean stale types.
5. Incorrect glob patterns
// ❌ Won't find files in subdirectories
loader: glob({ pattern: "*.md", base: "./src/content/blog" });
// ✅ Recursive pattern
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" });
6. Missing base path
// ❌ Relative to project root, might not find files
loader: glob({ pattern: "**/*.md", base: "content/blog" });
// ✅ Explicit path from project root
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" });
Troubleshooting Common Issues
”Collection not found” Error
Cause: Config file in wrong location or collection not exported.
Fix:
- Ensure config is at
src/content.config.ts(notsrc/content/config.ts) - Verify collection is exported:
export const collections = { blog }; - Run
npx astro sync
Schema Validation Errors
Cause: Frontmatter doesn’t match schema.
Fix:
- Check required fields are present
- Use
YYYY-MM-DDformat for dates - Ensure arrays use proper YAML syntax:
tags: ["a", "b"] - Check for typos in field names
TypeScript Errors After Schema Changes
Cause: Generated types are stale.
Fix:
npx astro sync
Run this after any schema modification.
Empty Collection in Production
Cause: Draft filter excluding all posts, or glob pattern not matching files.
Fix:
- Check your filter function:
({ data }) => !data.draft - Verify glob pattern matches your file extensions
- Ensure base path is correct relative to project root
Dev Server Hanging
Cause: Known issue with large collections in some Astro versions.
Fix:
- Update to latest Astro version
- Press
s + Enterin terminal to manually refresh - Check for circular references in your schema
Editor Integration and DX
JSON Schema for Data Files
Astro 4.13+ auto-generates JSON schemas in .astro/collections/. Configure VS Code to use them:
// .vscode/settings.json
{
"json.schemas": [
{
"fileMatch": ["src/content/authors/*.json"],
"url": "./.astro/collections/authors.schema.json"
}
]
}
YAML Schema Support
Install the Red Hat YAML extension, then add to your YAML files:
# yaml-language-server: $schema=../../.astro/collections/blog.schema.json
---
title: "My Post"
pubDate: 2024-11-09
---
VS Code Snippets for Frontmatter
Create .vscode/astro.code-snippets:
{
"Blog Post Frontmatter": {
"prefix": "blogfront",
"scope": "markdown,mdx",
"body": [
"---",
"title: \"$1\"",
"description: \"$2\"",
"pubDate: ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}",
"author: \"${3:Your Name}\"",
"tags: [\"$4\"]",
"draft: ${5:false}",
"---",
"",
"$0"
]
}
}
SEO and Structured Data
Content collections pair perfectly with structured data for SEO.
JSON-LD for Blog Posts
Create a reusable component:
---
// src/components/BlogPostSchema.astro
interface Props {
title: string;
description: string;
pubDate: Date;
updatedDate?: Date;
author: string;
image?: string;
url: string;
}
const { title, description, pubDate, updatedDate, author, image, url } =
Astro.props;
const schema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: title,
description: description,
datePublished: pubDate.toISOString(),
dateModified: (updatedDate || pubDate).toISOString(),
author: {
"@type": "Person",
name: author,
},
...(image && { image }),
mainEntityOfPage: {
"@type": "WebPage",
"@id": url,
},
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
Use in your blog post template:
---
import BlogPostSchema from "../components/BlogPostSchema.astro";
---
<head>
<BlogPostSchema
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
updatedDate={post.data.updatedDate}
author={post.data.author}
image={post.data.heroImage}
url={Astro.url.href}
/>
</head>
FAQ Schema from Collection Data
If your frontmatter includes FAQs (like this article), generate FAQ schema:
---
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: post.data.faqs?.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer,
},
})),
};
---
{
post.data.faqs && (
<script type="application/ld+json" set:html={JSON.stringify(faqSchema)} />
)
}
Quick Reference
Essential Commands
| Command | Purpose |
|---|---|
npx astro sync | Regenerate TypeScript types after schema changes |
npx astro dev | Start dev server with hot reload for content |
npx astro build | Build site, validates all content against schemas |
npx astro check | Type-check your project including content types |
Key Files
| File | Purpose |
|---|---|
src/content.config.ts | Define collections, loaders, and schemas |
.astro/types.d.ts | Auto-generated types (don’t edit) |
.astro/collections/*.schema.json | JSON schemas for editor integration |
API Functions
| Function | Import | Purpose |
|---|---|---|
getCollection() | astro:content | Fetch all entries in a collection |
getEntry() | astro:content | Fetch single entry by ID |
render() | astro:content | Render Markdown/MDX to HTML |
reference() | astro:content | Create cross-collection references |
defineCollection() | astro:content | Define a collection in config |
glob() | astro/loaders | Load files from directory |
file() | astro/loaders | Load entries from single file |
Key Takeaways
Before diving into the FAQ, here’s what you should remember:
-
Astro 5.0 changed everything — The Content Layer API replaces the legacy
type: "content"approach with loaders (glob(),file(), or custom). Config moved tosrc/content.config.ts. -
Type safety eliminates bugs — Zod schemas validate frontmatter at build time, not runtime. You’ll catch missing fields and wrong types before deployment.
-
Performance gains are massive — Expect 5x faster Markdown builds, 2x faster MDX, and 25-50% less memory usage compared to Astro 2-4.
-
Remote content is now first-class — Custom loaders can fetch from any CMS, API, or database with built-in caching and incremental updates.
-
Know when NOT to use collections — Real-time data, personalized content, and frequently-changing data should use SSR or Server Islands instead.
-
Always run
npx astro sync— After any schema change, regenerate types to keep TypeScript happy.
Next Steps
You now have everything needed to build production-ready content collections. Here’s where to go next:
- Build a blog — Start with the basic setup, add tags and RSS
- Explore Starlight — Astro’s documentation framework built on content collections
- Integrate a CMS — Try Storyblok, Contentful, or Sanity loaders for team workflows
- Add search — Generate a search index and implement client-side search with Pagefind or Fuse.js
For complex content architectures, consider combining collections with design systems for consistent, maintainable component libraries. If you’re adding interactive elements to your content, check out our guide on building interactive UI components.
For teams managing state across content-heavy applications, our React state management comparison covers the best approaches for 2026.
Last updated: March 2026. Last tested with Astro 5.x. This guide covers Astro 5.0+ with the Content Layer API.