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:
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.
---title: First postdescription: Why this blog exists.pubDate: 2026-11-01tags: - 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 |
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.
---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:
---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:
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.
Check your understanding
Question 1 of 6
0 of 6 checked