Content Collections
Most content-driven sites accumulate dozens or hundreds of Markdown files, plus JSON or YAML data that drives navigation, authors, or product listings. Content collections are Astro’s first-class way to organize, validate, and query that content with full TypeScript safety. Instead of globbing files by hand and hoping every frontmatter field exists, you declare a schema once and Astro guarantees that every entry conforms to it — turning a missing pubDate or a typo in draft into a build-time error rather than a runtime surprise.
What a collection is
A collection is a named group of related entries that share a common shape. A blog collection might hold Markdown posts, an authors collection might be a single JSON file of structured data, and a docs collection might mix Markdown and MDX. Each collection has:
- A loader that tells Astro where the entries come from (local files, a remote API, a CMS).
- A schema that validates and types the data attached to each entry.
Collections are defined in a single config file, src/content.config.ts (Astro 5) — older projects used src/content/config.ts. Astro reads this file at startup, validates every entry, and generates types so your editor autocompletes fields and flags mistakes.
Defining a collection
You define collections with defineCollection, give each a loader, and export a collections object. Schemas are written with Zod, re-exported by Astro as z.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };
The glob loader scans the base directory for files matching pattern. Every file’s frontmatter is parsed and validated against schema. If a post is missing title or sets pubDate to something Zod can’t coerce into a date, the build fails with a precise message naming the file and field.
Astro 5 introduced the Content Layer API, which makes loaders pluggable. The built-in
globandfileloaders cover local content, while custom loaders can pull from any data source. The legacytype: "content"/type: "data"syntax still works but is deprecated.
Querying entries
Once collections are defined, you read them with two async helpers from astro:content: getCollection returns every entry (optionally filtered), and getEntry returns a single one by collection and id.
---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
</li>
))}
</ul>
Because the schema is typed, post.data.title autocompletes and post.data.pubDate is a real Date. The filter callback runs at build time, so draft posts never reach the rendered HTML — and since this is a static page, it ships zero JavaScript by default.
Rendering an entry’s body
Markdown and MDX entries carry a render() method that compiles the body into a component you mount with the <Content /> tag.
---
// 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>
<Content />
</article>
Loaders at a glance
| Loader | Use it for | Source |
|---|---|---|
glob() | Many files, one entry each | Markdown, MDX, JSON, YAML on disk |
file() | One file containing many entries | A single JSON/YAML array |
| Custom loader | Remote data | CMS, API, database |
A custom loader is just an object with a load() method that calls store.set() for each entry — ideal for pulling posts from a headless CMS while keeping the same typed query API.
Best practices
- Keep one
content.config.tsper project and colocate all collection definitions there for a single source of truth. - Always provide a schema, even a minimal one — it is your contract and your autocomplete.
- Use
z.coerce.date()for date fields so string frontmatter parses cleanly intoDateobjects. - Give optional fields sensible
.default()values to avoidundefinedchecks in templates. - Filter drafts inside
getCollectionrather than in the template, so they never ship to production. - Prefer the
glob/fileloaders for local content and reach for custom loaders only when data lives elsewhere. - Reference fields with
reference()to link collections (for example, a post to its author) and keep relationships type-safe.