Astro content collections are named groups of content (Markdown, MDX, JSON, YAML, or data from an API) that Astro loads, validates against a schema and exposes through typed query functions. You define them in src/content.config.ts with a loader and a Zod schema, then read them with getCollection() and getEntry(). A post with a missing title or a malformed date fails the build instead of shipping a broken page.
This guide covers the Content Layer API as it works in Astro 7: loaders, schemas, queries, rendering, references between collections, and the errors you’re most likely to hit. Every snippet is copy-paste ready.
Key Takeaways
- Collections live in
src/content.config.ts. Each one needs aloader, usuallyglob()for folders of files orfile()for a single JSON/YAML file.- Schemas use Zod. Import
zfromastro/zod; theastro:contentre-export is scheduled for removal in Astro 8.- Entries have an
id(derived from the file path forglob()) and adataobject. Render Markdown withrender(entry)fromastro:content.reference()links entries across collections, such as a post to its author, and is validated at build time.- Custom loaders pull from a CMS or API into the same typed
getCollection()call.
What a content collection is
Without collections, a Markdown blog is a folder of files that you import and hope are consistent. With collections, Astro reads every file, parses frontmatter, checks it against your schema, generates TypeScript types, and stores the result in a data layer that pages query.
Three things come with that:
- Validation. The build stops on bad data and names the file and field.
- Types.
post.data.pubDateis aDatein your editor, with autocomplete. - One API for every source. Local Markdown, a JSON file and a headless CMS are all queried with the same functions.
Collections are optional. A site with three hand-written pages doesn’t need them. Anything with repeated content (posts, case studies, team members, products, docs pages, changelog entries) does.
Defining collections in src/content.config.ts
Create src/content.config.ts at the root of src/:
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob, file } 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(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
const authors = defineCollection({
loader: file('src/data/authors.json'),
schema: z.object({
name: z.string(),
url: z.url().optional(),
}),
});
export const collections = { blog, authors };
The key in the exported collections object becomes the collection name you query.
The glob() loader
glob() loads one entry per file. pattern is a glob relative to base. The pattern above, **/[^_]*.{md,mdx}, matches Markdown and MDX in any subfolder but skips files starting with an underscore, which is a handy way to keep drafts or partials out.
The entry id is the file path relative to base, without the extension and slugified. So src/content/blog/guides/first-post.md gets the id guides/first-post. To control it, set a slug field in that file’s frontmatter, or pass generateId to the loader.
The file() loader
file() loads many entries from one JSON or YAML file. The file must be an array of objects with an id field, or an object whose keys are ids:
[
{ "id": "komal", "name": "Komal Nakrani", "url": "https://komalnakrani.com/" },
{ "id": "guest", "name": "Guest Author" }
]
Use it for authors, categories, navigation, FAQs, anything small and structured.
Schemas with Zod
The schema describes what each entry’s data must look like. Astro 7 ships Zod 4, so the full Zod API is available.
Useful patterns for content:
schema: z.object({
title: z.string().max(70),
pubDate: z.coerce.date(), // accepts "2026-10-28" strings
status: z.enum(['draft', 'review', 'live']).default('draft'),
tags: z.array(z.string()).default([]),
rating: z.number().min(1).max(5).optional(),
canonical: z.url().optional(),
})
z.coerce.date()converts frontmatter strings toDateobjects..default()fills in missing values, so templates never seeundefinedfor that field.z.enum()catches typos in status or category fields.
Images in frontmatter
To validate and optimise a local image referenced in frontmatter, pass a function to schema and use the image() helper:
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: ({ image }) => z.object({
title: z.string(),
cover: image(),
coverAlt: z.string(),
}),
});
In the Markdown file, cover: ./cover.jpg is resolved relative to that file. In a template, post.data.cover is an image object you pass straight to <Image src={post.data.cover} alt={post.data.coverAlt} />. A missing file fails the build.
Querying collections
Import the query functions from astro:content.
getCollection()
Returns every entry, optionally filtered:
---
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', { dateStyle: 'medium' })}
</time>
</li>
))}
</ul>
getCollection() doesn’t guarantee order, so always sort explicitly.
A common filter shows drafts in development and hides them in production:
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.PROD ? !data.draft : true,
);
getEntry() and getEntries()
Fetch one entry by collection and id:
import { getEntry } from 'astro:content';
const author = await getEntry('authors', 'komal');
It returns undefined if the id doesn’t exist, so check before using it. getEntries() resolves an array of references in one call.
Rendering Markdown and MDX
Entries from Markdown or MDX files have a body and can be rendered. Import render from astro:content and call it with the entry:
---
// src/pages/blog/[...id].astro
import { getCollection, render } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
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, headings } = await render(post);
---
<BlogLayout title={post.data.title}>
<h1>{post.data.title}</h1>
<Content />
</BlogLayout>
The rest parameter [...id] matters when ids contain slashes (from subfolders). If every post sits at the top level, [id].astro works too.
render() returns three things:
Content, a component that renders the body.headings, an array of{ depth, slug, text }for every heading, which is everything you need for a table of contents.remarkPluginFrontmatter, any frontmatter added by remark plugins, which is how you add reading time to posts.
For MDX entries, you can map Markdown elements to your own components with <Content components={{ h2: MyHeading }} />.
Every Komal blog and editorial theme ships with its collections, schemas and post pages wired up. If you want to start writing today, browse Astro blog themes.
References between collections
reference() declares that a field points at an entry in another collection:
import { defineCollection, reference } from 'astro:content';
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 the id:
author: komal
related:
- astro-routing-guide
Astro checks that komal exists in authors at build time. In a template, resolve it:
const author = await getEntry(post.data.author);
const related = await getEntries(post.data.related);
This replaces a lot of hand-rolled lookup code. For an automatic version based on shared tags, see related posts without a plugin.
Building a blog index and post pages
A complete minimal blog needs three files: the config above, an index page and the dynamic post page.
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title="Blog" description="Notes on building with Astro.">
<h1>Blog</h1>
{posts.map((post) => (
<article>
<h2><a href={`/blog/${post.id}/`}>{post.data.title}</a></h2>
<p>{post.data.description}</p>
</article>
))}
</BaseLayout>
Add tag pages by collecting unique tags in getStaticPaths(). When the list gets long, split it with paginate(); the Astro routing guide shows both.
Custom loaders for CMS and API data
A loader is a function that fetches data and stores entries. The simplest form is an async function that returns an array of objects with id:
// src/content.config.ts
const releases = defineCollection({
loader: async () => {
const res = await fetch('https://api.github.com/repos/withastro/astro/releases?per_page=10');
const data = await res.json();
return data.map((r: any) => ({
id: String(r.id),
name: r.name,
publishedAt: r.published_at,
url: r.html_url,
}));
},
schema: z.object({
name: z.string(),
publishedAt: z.coerce.date(),
url: z.url(),
}),
});
This runs at build time. For larger sources, an object loader with a load() method can cache, store a digest per entry, and update incrementally between builds. Most headless CMSs publish a loader package you install and drop into loader:. Our headless CMS guide compares the options.
Live collections
Regular collections load at build time (or at server start). When data has to be fresh on every request, such as stock levels or prices, Astro supports live collections defined with defineLiveCollection() in src/live.config.ts and queried with getLiveCollection() and getLiveEntry() on on-demand pages. They need an adapter and return errors as values rather than throwing, so you handle a failed fetch in the page. Use them only where build-time data is genuinely too stale.
Common errors and how to fix them
“… frontmatter does not match collection schema.” The message names the file and field. Fix the frontmatter, or loosen the schema with .optional() or .default().
post.slug is undefined. Astro 5 replaced slug with id. Use post.id in links and getStaticPaths().
post.render is not a function. Rendering moved to a standalone function: import { render } from 'astro:content' and call render(post).
Collection is empty. Check base is relative to the project root (./src/content/blog) and that the pattern matches your file extensions. Restart the dev server after adding a new collection.
Types are stale. Run npx astro sync to regenerate types after schema changes, or restart astro dev.
Config file ignored. It must be src/content.config.ts. The old src/content/config.ts location only works behind the temporary legacy.collectionsBackwardsCompat flag.
The content collections reference lists every option, and the astro:content module reference documents each function’s signature.
Worked example: a case studies collection
Blogs are the usual example. Here’s a different one: an agency’s case studies, with a client reference, a cover image, results and a featured flag.
// src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
const clients = defineCollection({
loader: file('src/data/clients.json'),
schema: z.object({
name: z.string(),
industry: z.enum(['saas', 'retail', 'health', 'finance', 'other']),
}),
});
const caseStudies = defineCollection({
loader: glob({ pattern: '*.md', base: './src/content/case-studies' }),
schema: ({ image }) => z.object({
title: z.string(),
client: reference('clients'),
summary: z.string().max(200),
cover: image(),
coverAlt: z.string(),
results: z.array(z.object({ label: z.string(), value: z.string() })).min(1),
featured: z.boolean().default(false),
order: z.number().default(100),
}),
});
export const collections = { clients, caseStudies };
One entry:
---
title: "Rebuilding a checkout in six weeks"
client: northwind
summary: "A headless storefront for a 40-year-old retailer, shipped before peak season."
cover: ./northwind-cover.jpg
coverAlt: "Northwind product page on a phone"
results:
- { label: "Build time", value: "6 weeks" }
- { label: "Pages migrated", value: "1,200" }
featured: true
order: 1
---
The brief arrived in August...
A home page section that shows featured work, with the client name resolved:
---
import { getCollection, getEntry } from 'astro:content';
import { Image } from 'astro:assets';
const featured = (await getCollection('caseStudies', ({ data }) => data.featured))
.sort((a, b) => a.data.order - b.data.order)
.slice(0, 3);
const items = await Promise.all(
featured.map(async (study) => ({
study,
client: await getEntry(study.data.client),
})),
);
---
<section>
{items.map(({ study, client }) => (
<a href={`/work/${study.id}/`} class="case">
<Image src={study.data.cover} alt={study.data.coverAlt} width={640} />
<p class="client">{client?.data.name}</p>
<h3>{study.data.title}</h3>
</a>
))}
</section>
What the schema buys you here: a typo in client fails the build, a case study without results fails the build, a missing cover image fails the build, and order gives editors control of the sequence without touching code. That’s the difference between “a folder of Markdown” and content you can trust.
Migrating from Astro 4 collections
If you’re upgrading an older project, these are the changes, in the order you’ll hit them:
- Move the config. Rename
src/content/config.tstosrc/content.config.ts. - Add loaders. Replace
type: 'content'withloader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), andtype: 'data'withglob()for folders of JSON/YAML orfile()for single files. - Swap
slugforid. UpdategetStaticPaths(), links andgetEntry()calls. If you relied on custom slugs in frontmatter, theglob()loader uses aslugfrontmatter field as theidwhen present. - Swap
entry.render()forrender(entry). Importrenderfromastro:content. - Import
zfromastro/zod. - Run
npx astro syncand fix the type errors it surfaces.
If you can’t migrate everything at once, legacy.collectionsBackwardsCompat: true in config keeps the old behaviour temporarily. Treat it as a bridge, not a destination.
How collections are organised in a production theme
In Komal themes, collections are set up so editing is mostly writing Markdown:
- Content sits in
src/content/<collection>/, one file per entry. - The schema in
src/content.config.tsdocuments every field a writer can set. - Pages under
src/pages/only query and render. They hold no content.
The content docs walk through adding a post to a theme step by step.
FAQ
Where do I put content collections in Astro 7?
Define them in src/content.config.ts. The content files themselves can live anywhere; the base option of glob() points at the folder. src/content/ is the convention.
What is the difference between glob() and file()?
glob() creates one entry per file in a folder, which suits posts and pages. file() creates many entries from a single JSON or YAML file, which suits authors, categories or settings.
Can I use content collections with MDX?
Yes. Install the MDX integration with npx astro add mdx and include mdx in your glob() pattern. MDX entries render with render() like Markdown, and can import components.
Do content collections work with on-demand rendering?
Yes. Build-time collections are available on on-demand pages. For data that must be fetched per request, use live collections.
How do I sort a collection by date?
getCollection() returns entries in no guaranteed order. Sort the result: posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()).