React
Astro content collections: schemas, types and queries
A collection is a Zod schema plus a loader: Astro validates files at build time, generates the types and hands you getCollection.
Key points
- A collection is a schema plus a loader, not a special folder.
- The config file in Astro 5 is src/content.config.ts.
- render(entry) replaces entry.render(), and id replaces slug.
An Astro content collection is a folder of files plus a Zod schema: the loader reads the files, the schema validates them at build time and getCollection() returns typed objects. No frontmatter left to chance.
A config file, not a magic folder
// 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(),
publishedAt: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };
The file goes in src/content.config.ts. Astro 4 used src/content/config.ts: the new location exists because a collection can also read files outside src/content.
Reading a collection in a page
---
import { getCollection, render } from "astro:content";
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
---
{posts.map(async (post) => {
const { Content } = await render(post);
return {post.data.title};
})}
The second argument of getCollection is an in-memory filter, not a query: the collection stays typed and the filter receives validated data. For a single entry use getEntry("blog", id).
One dynamic route per entry
---
import type { GetStaticPaths } from "astro";
import { getCollection, render } from "astro:content";
export const getStaticPaths = (async () => {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}) satisfies GetStaticPaths;
const { post } = Astro.props;
const { Content } = await render(post);
---
The [id].astro file calls getStaticPaths and returns one entry per published post: the build writes a page for each, with no runtime query. A collection of 638 files produces 638 pages, and every page receives the already validated entry in Astro.props.
Where the constraints live
| Field | Schema | What it prevents |
|---|---|---|
| publishedAt | z.coerce.date() | A date written as a string that stays a string |
| draft | z.boolean().default(false) | A post without the field breaking the build |
| cover | z.string().startsWith('/') | An external path that breaks the images |
| author | reference('authors') | A reference to an author who does not exist |
Validation runs when the loader reads the files: on build and in the dev server. It is not a runtime check, so a response fetched from an API never passes through it.
node -e "const fs=require('fs');const d='data/blog';let n=0,b=0;for(const f of fs.readdirSync(d)){if(!f.endsWith('.json')||f[0]==='.')continue;n++;b+=JSON.parse(fs.readFileSync(d+'/'+f,'utf8')).body.length;}console.log(n+' files, '+(b/n).toFixed(2)+' blocks each')"
The errors you actually see
astro syncskipped: your editor does not know the types and flags everyentry.dataasany.- The
slugkey used after the upgrade to Astro 5: the field is now calledid. entry.render()in an Astro 5 page: the function isrender(entry), imported fromastro:content.- Schema left in
src/content/config.tswith the new loader: the file is never read. - Reading files with
fsinside the page: it works on the laptop and breaks on the host, wheresrc/does not exist at runtime.
The decision rule
Rule: if the content lives in the repository and changes with a commit, glob() is the choice; if it changes without a deploy, such as a price or a stock level, you need a loader that reads from an API and the collection stays the place for the schema. The signal is frequency: more edits per day than deploys per day moves the boundary.
The schema grows with the content. Once the fields pass ten, pull the shared objects, seo, cover, author, into Zod variables and reuse them across collections: one change to the alt field touches a single place instead of four.
The known limitation
A collection is not a CMS. The glob() loader reads files inside the repository, so every content change needs a deploy. For content that changes hourly you need a remote loader or an API route; the collection remains the home of the schema, not of the writing.
Commands
npx astro sync
npx astro check
From the catalogue: the Astro Association components, the Astro logo block and glass cards. The guides: the missing client directive and view transitions in Astro; the comparison: Astro against Next.js for documentation.
Frequently asked questions
Where does the config file go in Astro 5?
In src/content.config.ts. Astro 4 used src/content/config.ts: that path still works, but the new one is what the docs describe.
Can I use collections without Zod?
Yes, the schema is optional. Without it you lose validation and generated types, so keep at least the fields your template reads.
Do collections work with MDX?
Yes. The loader glob pattern must include mdx, otherwise those files stay outside the collection.
How do I filter out unpublished posts?
Add a boolean field to the schema, for example draft with a default of false, and filter inside getCollection.
A collection or an API route?
A collection for files versioned in the repository, an API route for content that changes without a deploy. The schema matters in both cases.