Content Collections turn a folder of Markdown into a typed, queryable database. The catch in 2026: nearly every tutorial you’ll find online still shows the Astro 4 API, which Astro 6 removed. This walkthrough uses the current Content Layer API end to end, so the code actually compiles.
TL;DR
- On Astro 6 (stable since March 10, 2026; current release line is 6.4), collections are defined with a loader in
src/content.config.ts— not the oldsrc/content/config.tswithtype: 'content'. - Local Markdown/MDX uses the
glob()loader; JSON/YAML usesfile(). Both come fromastro/loaders. entry.slugis gone — useentry.id.entry.render()is gone — importrenderfromastro:contentand callrender(entry).- Zod now imports from
astro/zod(Astro 6 ships Zod 4), though the re-export fromastro:contentstill works. - Setup takes about 30 minutes once and saves you on every field rename, slug change, or bad-frontmatter bug afterward.
What changed, and why old tutorials break
If you copy a content-collections snippet from a 2024 blog post into a fresh Astro 6 project, it will fail. The Content Layer API landed in Astro 5.0 and the legacy collections API was fully removed in 6.0. Three concrete breakages:
| Astro 4 (dead) | Astro 5/6 (current) |
|---|---|
src/content/config.ts | src/content.config.ts (project root, beside astro.config) |
defineCollection({ type: 'content', ... }) | defineCollection({ loader: glob({ ... }), ... }) |
entry.slug | entry.id |
await entry.render() | await render(entry) (import from astro:content) |
import { z } from 'astro:content' | import { z } from 'astro/zod' (re-export still works) |
The payoff for the rewrite is real: Astro’s own numbers put Markdown builds up to 5x faster and MDX up to 2x faster than the legacy pipeline, with 25–50% lower memory use — which matters once a site crosses a few hundred pages.
When Content Collections are worth it
- You have or expect more than ~30 Markdown / MDX files.
- You want a failed build (not a broken page) when frontmatter is wrong.
- Multiple templates read the same content: homepage, hub pages, sitemap, RSS feed.
- You’ll maintain the site for years, not weeks.
If you’re starting an Astro content site in 2026, wire this up on day one. Retrofitting a 200-post folder later is far more painful than learning the schema now.
Step by step
1. Drop a few MDX files with frontmatter
Create src/content/blog/ and add a post:
---
title: "Hello world"
description: "A test post to verify content collections work end to end."
publishedAt: 2026-05-22
tags: ["intro", "test"]
author: "alice"
---
This is the body.
The glob loader reads .md, .mdx, .markdoc, .json, .yaml, and .toml, so you can mix data and prose under one base path.
2. Define the schema in src/content.config.ts
This file lives at the project root, next to astro.config.mjs — not inside src/content/. The schema is the contract: any file that violates it fails the build with a precise message.
import { defineCollection, reference } 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: ({ image }) => z.object({
title: z.string().min(10).max(80),
description: z.string().min(120).max(160),
publishedAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(),
tags: z.array(z.string()).min(1),
cover: image().optional(),
author: reference('authors'),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
}),
});
const authors = defineCollection({
loader: file('src/content/authors.json'),
schema: z.object({
id: z.string(),
name: z.string(),
bio: z.string(),
twitter: z.string().optional(),
}),
});
export const collections = { blog, authors };
Two things worth noting: z.coerce.date() parses the YAML date string for you, and image() is still only available inside the schema-context callback (({ image }) => ...). It validates the file exists on disk and routes it through Astro’s image optimizer — storing a bare string path skips that entirely.
3. Run the dev server and let it validate everything
npm run dev
# [content] Could not parse frontmatter in blog → old-post.mdx
# description: String must contain at most 160 character(s)
Astro checks every entry against the schema and points at the exact file and field. Fix everything red before moving on — that loud failure is the whole point.
4. Add the author data
Because the authors collection uses the file() loader, it’s a single JSON array, not one file per author:
[
{ "id": "alice", "name": "Alice Chen", "bio": "Indie dev writing about content sites.", "twitter": "alicewrites" }
]
5. Render a typed list page
getCollection returns fully typed entries, so your editor autocompletes entry.data.title and flags typos at build time:
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => +b.data.publishedAt - +a.data.publishedAt);
---
<ul>
{posts.map((p) => (
<li>
<a href={`/blog/${p.id}/`}>{p.data.title}</a>
<time datetime={p.data.publishedAt.toISOString()}>
{p.data.publishedAt.toLocaleDateString()}
</time>
</li>
))}
</ul>
One gotcha on Astro 5/6: getCollection no longer guarantees a stable order, so always .sort() yourself if order matters.
6. Build the per-article route
The route file uses [...id] (rest param) so IDs containing slashes still resolve. Note render(entry) is now a top-level import, and the author reference is resolved with getEntry:
---
// src/pages/blog/[...id].astro
import { getCollection, getEntry, render } from 'astro:content';
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 author = await getEntry(post.data.author);
const { Content } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<p>By {author?.data.name}</p>
<Content />
</article>
7. Sanity-check the production build
npm run build
# 09:42:11 [build] 1209 page(s) built in 78.40s
# 09:42:11 [build] Complete!
If the build is clean, no malformed frontmatter slipped through — the schema enforced it for you.
Common pitfalls
- Leaving the config at
src/content/config.ts. On Astro 6 it’s ignored; collections silently come back empty. Move it tosrc/content.config.tsat the project root. - Copying
type: 'content'from an old tutorial. That key no longer exists. Every collection needs aloader. - Reaching for
entry.slugorentry.render(). Useentry.idandawait render(entry). - Marking required fields
.optional()“to make the build pass.” That just lets bad data through to production. - Editing the schema without restarting
dev. Generated types are cached; a restart regenerates.astro/. - Expecting Live Content Collections to do everything. In Astro 6 the live variant is stable for request-time data, but it does not support
image()or MDX rendering — keep editorial content in build-time collections.
When to skip this
Tiny sites under ~10 pages where the schema overhead never pays back. If you’re hand-writing a handful of static pages, plain .astro files are simpler.
FAQ
- Which config file does Astro 6 use?:
src/content.config.tsat the project root (next toastro.config.mjs). The oldsrc/content/config.tspath was for Astro 4 and is ignored now. - Do Content Collections still work with MDX?: Yes. The
glob()loader handles.mdand.mdx(plus Markdoc, JSON, YAML, TOML). MDX files can render components inline. Live collections are the exception — they can’t render MDX. - What replaced
entry.render()andentry.slug?: Importrenderfromastro:contentand callrender(entry); entries are now plain objects identified byentry.idinstead ofentry.slug. - Where do I store JSON or YAML data like authors or tags?: Use the
file()loader fromastro/loaderspointed at a single JSON/YAML file containing an array; reference rows from other collections withreference('authors'). - What happens when frontmatter is invalid?: The build fails with a message naming the file and field. That’s the feature — bad data never reaches production.
- Do I have to rewrite my Astro 4 collections to upgrade?: Yes for anything still on the legacy API, since 6.0 removed it. Swap in a
loader, move the config file, and replaceslug/render()call sites.
Related
- Building a Markdown / MDX content site that scales
- When Astro is the right choice (and when it isn’t)
- Building category and tag pages in Astro
External references: Astro Content Collections guide and the Astro 6.0 release notes.
Tags: #Indie dev #Astro #Content Collections #MDX #Getting started