Defining Collections
A collection is a named group of related content entries that share the same shape — blog posts, docs pages, authors, products. In Astro you declare each collection in a single config file using defineCollection, wire up a loader that tells Astro where the data lives, and attach a schema that validates every entry at build time. Getting this configuration right means type-safe queries, autocomplete in your editor, and build-time errors instead of broken pages in production.
Where collections are defined
Astro looks for a single configuration file at the root of src/. Since Astro 5 the canonical location is src/content.config.ts (the older src/content/config.ts path still works for backwards compatibility, but prefer the new one).
This file must export a single collections object. Each key becomes a collection name you can query later, and each value is the result of calling defineCollection.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.date(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
The astro:content virtual module is provided by Astro itself — there is nothing to install. z is a re-exported Zod instance, so you never need a separate dependency for schemas.
The shape of defineCollection
defineCollection accepts an options object with two fields you will use constantly: loader and schema.
| Option | Required | Purpose |
|---|---|---|
loader | Yes | Tells Astro how to find and read entries (files, glob, or API data) |
schema | No | A Zod schema (or function) that validates and types each entry |
The loader is what makes Astro 5 collections flexible: it can read local Markdown with glob, a single structured file with file, or anything at all through a custom loader. The schema then guarantees that whatever the loader returns matches the structure your templates expect.
Tip: A collection without a
schemastill works — entries are loaded as-is anddatais typed loosely. Add a schema as soon as the collection has more than one field; the build-time validation pays for itself immediately.
Attaching a loader
The loader is the bridge between your source data and the collection. The two built-in loaders cover the most common cases.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob, file } from 'astro/loaders';
// Many files, one entry per file
const docs = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }),
schema: z.object({
title: z.string(),
order: z.number(),
}),
});
// One JSON/YAML file, one entry per array element
const authors = defineCollection({
loader: file('src/data/authors.json'),
schema: z.object({
id: z.string(),
name: z.string(),
twitter: z.string().optional(),
}),
});
export const collections = { docs, authors };
With glob, every matching file becomes an entry and its id is derived from the file path relative to base. With file, the file must contain an array (JSON) or a top-level map, and each item becomes one entry. For data that lives behind an API or a CMS, you write a custom loader instead — see the linked pages below.
Validating with a schema
The schema is a Zod object describing the front-matter (or data fields) of each entry. Astro runs it during astro dev and astro build; a mismatch fails the build with a precise, file-level error.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string().max(80),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
author: z.enum(['ada', 'linus', 'grace']),
}),
});
export const collections = { blog };
If a post is missing description or sets an unknown author, you get an error like this rather than a runtime crash:
Output:
[InvalidContentEntryDataError] blog → my-post.md frontmatter does not match collection schema.
description: Required
author: Invalid enum value. Expected 'ada' | 'linus' | 'grace', received 'jane'
Schemas as a function (image helper)
When a field references a local image you want Astro to optimize, declare the schema as a function so you receive the image() helper.
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
cover: image(),
coverAlt: z.string(),
}),
});
The cover field is now validated to be a real image on disk and is typed as an ImageMetadata object you can pass straight to the <Image /> component.
Using the inferred types
Because the schema is Zod, Astro generates types automatically. Your .astro pages get full autocomplete on entry.data with no manual typing.
---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
<ul>
{posts.map((post) => (
<li>{post.data.title} — {post.data.pubDate.toDateString()}</li>
))}
</ul>
This is the heart of Astro’s zero-JS-by-default model: all of this validation, querying, and rendering happens at build time, and the page above ships as static HTML with no client JavaScript.
Best practices
- Keep a single
src/content.config.tsand export onecollectionsobject — Astro only reads this one file. - Always attach a
schemaonce a collection has more than a trivial shape; let build-time errors catch bad front-matter early. - Use
z.coerce.date()for date fields so string front-matter parses cleanly intoDateobjects. - Prefer
globfor file-per-entry content andfilefor a single structured data file; reach for a custom loader only when the data is remote. - Use the function form of
schemawhenever entries reference local images so you get theimage()helper and optimized assets. - Store collection source files outside
src/pages/(e.g.src/data/orsrc/content/) so they are not accidentally turned into routes.