A Guide to Astro Content Collections
A common approach when building a blog site with Astro is to keep posts as markdown files in a pages directory and query them with import.meta.glob(). This works, but errors in frontmatter fields only surface when builds fail.
Content Collections solve this with build-time validation. Define a schema, and Astro checks every file against it before the build completes.
What Are Content Collections?
Content Collections add a validation layer between your content files and your site. You write a Zod schema that defines what fields each piece of content needs. Astro validates everything at build time and generates TypeScript types automatically.
Collections work with multiple data sources: markdown files, JSON, external APIs, and CSV files.
Setting Up a Collection
Create a content.config.ts file in the src directory to define collections:
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.coerce.date(),
description: z.string(),
tags: z.array(z.string()).optional(),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog };
The glob loader looks for files matching the specified pattern. The z.coerce.date() automatically converts date strings like 2025-01-15 into Date objects.
Content files go in src/content/blog/:
---
title: "Getting Started with Astro"
date: 2025-01-15
description: "My journey building fast websites with Astro"
tags: ["astro", "javascript"]
---
Your content here...
Loading Different Kinds of Data
The glob loader is great for markdown files, but collections can load from almost anywhere.
For a single JSON file, use the file() loader:
import { file } from "astro/loaders";
const authors = defineCollection({
loader: file("src/data/authors.json"),
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string(),
twitter: z.string().optional(),
}),
});
You can even load data from external APIs using a custom loader:
const countries = defineCollection({
loader: async () => {
const response = await fetch("https://restcountries.com/v3.1/all");
const data = await response.json();
return data.map((country) => ({
id: country.cca3,
name: country.name.common,
population: country.population,
flag: country.flags.svg,
}));
},
schema: z.object({
name: z.string(),
population: z.number(),
flag: z.string().url(),
}),
});
This runs at build time, so your site stays static while pulling in fresh data whenever you rebuild.
Relationships Between Collections
You can create references between collections to link related data. For example, linking blog posts to authors or posts to related posts.
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({
name: z.string(),
bio: z.string(),
avatar: z.string(),
}),
});
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
author: reference("authors"),
relatedPosts: z.array(reference("blog")).optional(),
}),
});
export const collections = { blog, authors };
Reference entries by their ID in markdown frontmatter:
---
title: "Building with Content Collections"
date: 2025-01-20
author: jane-doe
relatedPosts:
- getting-started-with-astro
- astro-vs-next
---
Fetch the referenced data in templates:
---
import { getEntry, getEntries } from "astro:content";
const post = await getEntry("blog", Astro.params.id);
const author = await getEntry(post.data.author);
const relatedPosts = post.data.relatedPosts
? await getEntries(post.data.relatedPosts)
: [];
---
<article>
<h1>{post.data.title}</h1>
<p>By {author.data.name}</p>
{relatedPosts.length > 0 && (
<aside>
<h2>Related Posts</h2>
{relatedPosts.map(related => (
<a href={`/blog/${related.id}`}>{related.data.title}</a>
))}
</aside>
)}
</article>
If you reference an author or post that doesn’t exist, Astro catches it at build time.
Querying Your Collections
The most common way to fetch content is with getCollection():
import { getCollection } from "astro:content";
const allPosts = await getCollection("blog");
You can filter inline to hide drafts or future-dated posts:
const publishedPosts = await getCollection("blog", ({ data }) => {
return !data.draft && data.date <= new Date();
});
For production builds, you might want different behavior:
const posts = await getCollection("blog", ({ data }) => {
// Show drafts in dev, hide them in production
return import.meta.env.PROD ? !data.draft : true;
});
If you know exactly which entry you need, use getEntry():
import { getEntry } from "astro:content";
const post = await getEntry("blog", "my-first-post");
if (!post) {
return Astro.redirect("/404");
}
Working with Images
Astro has a special image() helper for validating images in your schema:
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: ({ image }) =>
z.object({
title: z.string(),
coverImage: image(),
coverAlt: z.string(),
}),
});
Reference images relative to the markdown file in frontmatter:
---
title: "My Post"
coverImage: ./cover.jpg
coverAlt: "A beautiful sunset"
---
Astro validates that the image exists and provides proper TypeScript types. Use it with the Image component:
---
import { Image } from "astro:assets";
const { data } = post;
---
<Image src={data.coverImage} alt={data.coverAlt} />
Dynamic Routes with Collections
To create individual pages for each post, use getStaticPaths():
---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<time>{post.data.date.toLocaleDateString()}</time>
<Content />
</article>
The render() function compiles your markdown and returns a Content component, plus some extras like headings (useful for generating a table of contents) and remarkPluginFrontmatter (if you’re using remark plugins that modify frontmatter).
Advanced Schema Patterns
Zod supports various validation and transformation patterns:
const blog = defineCollection({
schema: z.object({
// Required string
title: z.string(),
// String with default value
author: z.string().default("Anonymous"),
// Date from string (e.g., "2025-01-15")
publishDate: z.coerce.date(),
// Transform a string
slug: z.string().transform((str) => str.toLowerCase()),
// Enum for limited choices
status: z.enum(["draft", "published", "archived"]),
// Optional field
excerpt: z.string().optional(),
// Email validation
contactEmail: z.string().email().optional(),
// URL validation
canonicalUrl: z.string().url().optional(),
// Nested object
seo: z
.object({
metaTitle: z.string(),
metaDescription: z.string(),
})
.optional(),
}),
});
Practical Notes
Defining schemas upfront catches mistakes early and gives you autocomplete in your editor for frontmatter fields.
References between collections work well for author bios, related content, and category linking. The type safety makes refactoring easier.
You can mix different loaders in the same project. A blog collection can use glob for markdown files while an authors collection uses the file loader for JSON.
Using z.coerce.date() means you can write dates as strings in frontmatter (date: 2025-01-15) and Zod converts them to Date objects automatically.
For draft posts, you can filter differently based on environment. Show drafts in development but hide them in production with import.meta.env.PROD.
Summary
Content Collections provide the foundation for content-heavy Astro sites. With a typed API for your content, other features like RSS feeds, sitemaps, and pagination become more straightforward to implement.
If you’re using import.meta.glob() or reading files manually, Content Collections offer better type safety and developer experience.
Written by
Machiel van der Walt
I build tools that make work and life easier.