Photo by chansu shin on Unsplash

On this page

This chapter follows Routing in Astro.

You can put Markdown files straight into src/pages/, and each becomes a page. That stops scaling once you need a list page, tag pages, a required pubDate, or the same posts in an RSS feed. Content collections separate the content from the routes: you describe the data once, Astro validates every entry, and pages query it like a small typed database.

Define a collection

Collections live in src/content.config.ts. Each one has a loader that finds entries and a schema that validates them:

src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
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 files can live anywhere; base tells the loader where to look. This knowledgebase defines a single articles collection with the same glob loader over src/content/articles, and a custom generateId so that each article’s ID is its filename.

Validate frontmatter with a schema

The schema is a Zod object. If an entry’s frontmatter does not match, the dev server and the build fail with the file name and the failing field, so a missing description never reaches production silently.

src/content/blog/first-post.md
---
title: First post
description: Why this blog exists.
pubDate: 2026-11-01
tags:
- astro
---
The body is regular Markdown.

Schemas can do more than check types. z.coerce.date() turns a YAML date into a Date. .default() fills missing fields. .refine() and .superRefine() enforce rules across fields; this site uses superRefine to require series and seriesOrder together. For images in frontmatter, pass a function to schema and use its image() helper, which validates the path and makes the file usable with <Image />:

schema: ({ image }) =>
z.object({
title: z.string(),
cover: image().optional(),
}),

Loaders: files, data, and remote sources

Loader Source Entry ID
glob() Many files matching a pattern (Markdown, MDX, JSON, YAML) Path relative to base, unless generateId is set
file() One JSON or YAML file holding many entries The id field of each item
Custom Anything you can fetch: a CMS, an API, a database Whatever the loader sets
src/content.config.ts
import { defineCollection } from 'astro:content';
import { file } from 'astro/loaders';
import { z } from 'astro/zod';
const authors = defineCollection({
loader: file('src/data/authors.json'),
schema: z.object({
id: z.string(),
name: z.string(),
url: z.string().url().optional(),
}),
});

A loader can also be an async function that returns an array of entries, which is the quickest way to pull data from an API into the same typed query interface. Loaded data is cached between builds.

Query entries

getCollection returns every entry, optionally filtered. getEntry returns one by ID. Each entry has an id, typed data, and for Markdown a body.

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('en-US')}
</time>
</li>
))}
</ul>

Collections have no built-in sort order, so sort explicitly. Filtering drafts in the query, as above, is the usual way to keep them out of production lists.

Render an entry as a page

Combine a collection with a dynamic route. getStaticPaths creates one path per entry, and render() turns the Markdown into a component:

src/pages/blog/[id].astro
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
---
<BaseLayout title={post.data.title}>
<h1>{post.data.title}</h1>
<Content />
</BaseLayout>

headings lists every heading with its depth, slug, and text, which is how a sidebar table of contents is built. This site builds its article sidebar from the ## and ### entries in that list.

Reference other entries

reference() links one collection to another by ID and validates that the target exists:

src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
author: reference('authors'),
related: z.array(reference('blog')).default([]),
}),
});

In frontmatter you write author: ada. In a page, resolve it with await getEntry(post.data.author), or many at once with getEntries(post.data.related). A typo in an author ID now fails the build instead of rendering an empty byline.

Next: Islands and client directives.

Check your understanding

Question 1 of 6

0 of 6 checked

  1. 1. Which two parts does a collection definition have?

Search articles

Type a word to search published articles.

Open the search page