Content Collections
Defining a collection
Section titled “Defining a collection”Collections live in one config file: src/content.config.ts. Each collection is a call to defineCollection with two parts — a loader (where the content comes from) and a schema (its validated shape):
import { defineCollection } from 'astro:content';import { z } from 'astro/zod';import { glob } from 'astro/loaders';
const blog = defineCollection({ // loader: read every .md/.mdx file under src/data/blog (skipping _drafts) loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/data/blog' }), // schema: validate each file's frontmatter schema: z.object({ title: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), }),});
export const collections = { blog };The key rule since Astro 5 (unchanged in Astro 7): every collection must declare a loader. The old behavior — dropping files in src/content/blog/ and having them auto-collected — is gone. Being explicit means the same API can load local files today and a remote API tomorrow, with no change to how you query.
Loaders: where content comes from
Section titled “Loaders: where content comes from”Astro ships two built-in loaders from astro/loaders:
glob()— load many files matching a pattern. Each file becomes one entry. Use it for a folder of blog posts or docs pages:glob({ pattern: '**/*.md', base: './src/data/blog' }).file()— load many entries from a single data file (a JSON or YAML array). Use it for structured data like authors or products:file('./src/data/authors.json').
The pattern **/[^_]*.{md,mdx} is worth reading: it matches .md and .mdx files in any subfolder, but the [^_] excludes files starting with an underscore — a handy convention for drafts.
Beyond these, the loader is a documented interface, so the ecosystem provides loaders for CMSs and APIs; you can also write your own to pull from any source.
Writing a custom loader
Section titled “Writing a custom loader”A loader is just an object with a name and a load() function — glob and file are built with the same contract you can use yourself. load() receives a LoaderContext with the pieces you need to fill the content store by hand: store (write entries into the collection), parseData (validate raw data against the schema before storing it), meta (a small key-value store, scoped to the collection, that survives across builds — good for sync tokens or last-modified timestamps), and generateDigest (hash an entry’s data so you can tell whether it changed).
import type { Loader } from 'astro/loaders';import { z } from 'astro/zod';
// A tiny custom loader: no filesystem, no network — just the loader// contract (store, meta, parseData, generateDigest) against a fixed feed.const FEED = [ { id: 'note-1', title: 'First note', body: 'Hand-written note one.' }, { id: 'note-2', title: 'Second note', body: 'Hand-written note two.' },];
export function notesLoader(): Loader { return { name: 'notes-loader', schema: z.object({ title: z.string(), body: z.string() }), load: async ({ store, meta, parseData, generateDigest, logger }) => { const lastRun = meta.get('lastRun'); logger.info(`notes-loader: lastRun=${lastRun ?? 'never'}`);
store.clear(); for (const item of FEED) { const data = await parseData({ id: item.id, data: item }); const digest = generateDigest(data); store.set({ id: item.id, data, digest }); } meta.set('lastRun', new Date().toISOString()); }, };}// src/content.config.ts — the same file, extended with a third collectionimport { defineCollection } from 'astro:content';import { z } from 'astro/zod';import { glob } from 'astro/loaders';import { notesLoader } from './lib/notes-loader';
const blog = defineCollection({ loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/data/blog' }), schema: z.object({ title: z.string(), pubDate: z.coerce.date(), draft: z.boolean().default(false), }),});
const notes = defineCollection({ loader: notesLoader() });
export const collections = { blog, notes };Running astro sync (or astro dev/astro build, which sync first) calls load() for real. Proven in the probe — the loader’s own logger.info line shows up in the sync output, and a second run shows meta survived from the first:
$ npx astro sync04:54:09 [content] Syncing content04:54:09 [notes-loader] notes-loader: lastRun=never04:54:09 [content] Synced content04:54:09 [types] Generated 1.16s
$ npx astro sync04:54:19 [content] Syncing content04:54:19 [notes-loader] notes-loader: lastRun=2026-09-25T21:54:09.825Z04:54:19 [content] Synced contentAlmost every project reaches for glob or file first — write a custom loader only when content really comes from somewhere neither can reach (an API with pagination, a database, a feed that needs incremental sync via meta).
flowchart LR
src["files: src/data/blog/*.md"] --> loader["glob loader reads files"]
loader --> schema["schema validates frontmatter"]
schema --> store["typed entries in the content store"]
store --> get["getCollection('blog')"]
get --> page["page renders each entry"]Querying a collection
Section titled “Querying a collection”Once defined, you query a collection from any component script with two functions from astro:content:
---import { getCollection, getEntry } from 'astro:content';
// All published posts, newest firstconst posts = (await getCollection('blog')) .filter((post) => !post.data.draft) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// A single entry by its idconst featured = await getEntry('blog', 'hello-world');---<ul> {posts.map((post) => ( <li> <a href={`/blog/${post.id}`}>{post.data.title}</a> </li> ))}</ul>Each entry has an id (derived from its filename) and a data object — the validated frontmatter, fully typed from your schema. Because validation already ran, post.data.pubDate is a real Date, not a string; your editor autocompletes the fields.
Rendering an entry’s body
Section titled “Rendering an entry’s body”Querying gives you an entry’s frontmatter, but the Markdown body isn’t HTML yet. To render it, call render (from astro:content) on the entry — it returns a <Content /> component you drop into the template:
---import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'hello-world');if (!post) throw new Error('Post not found');
const { Content } = await render(post);---<article> <h1>{post.data.title}</h1> <time>{post.data.pubDate.toDateString()}</time> <Content /></article>render compiles the entry’s Markdown/MDX to a component, so <Content /> outputs the finished HTML — headings, code blocks, and any components used in MDX all rendered.
render(entry) is the standalone function form — the old entry.render() method is gone. Entries returned by getCollection/getEntry are plain serializable objects (so they can, for example, be passed as page props from getStaticPaths); a method would not survive that. entry.render() was deprecated starting in Astro 5 and Astro 6 removed it outright. Proven in the probe — swapping render(post) for post.render() on Astro 7.3.5 fails the build with a real runtime error, not a warning:
[ERROR] TypeError: post.render is not a function at .../chunks/index_*.mjs:699:33 at async renderToString (.../chunks/server_*.mjs:1861:25)[ERROR] [build] Caught error rendering /: TypeError: post.render is not a functionastro sync and generated types
Section titled “astro sync and generated types”Every collection you define gets its own generated TypeScript type, written to .astro/content.d.ts by astro sync (Astro also runs this automatically before dev/build/check). Query results are typed through CollectionEntry<'blog'>, imported from astro:content — useful anywhere you want to type a prop or a helper function without repeating the shape by hand:
import type { CollectionEntry } from 'astro:content';
export function formatPost(post: CollectionEntry<'blog'>) { return `${post.data.title} — ${post.data.pubDate.toDateString()}`;}This isn’t a hand-written interface — it’s generated straight from your content.config.ts. Proven in the probe: after astro sync, .astro/content.d.ts contains a DataEntryMap with one entry per collection, and CollectionEntry<C> is defined as Flatten<DataEntryMap[C]>:
// .astro/content.d.ts (generated — real output for the `blog` collection above)"blog": Record<string, { id: string; body?: string; collection: "blog"; data: InferEntrySchema<"blog">; rendered?: RenderedContent; filePath?: string; digest?: string | number;}>;InferEntrySchema<'blog'> is itself generated from the Zod schema (import('astro/zod').infer<...>) — so the schema is the one source of truth for both runtime validation and the type your editor shows you. If content.config.ts changes and you don’t see the new fields autocomplete, run astro sync (or restart astro dev, which does it for you).
Mental Model
Section titled “Mental Model”A collection is not “a folder of Markdown” — it’s a small typed database with exactly one write path (the loader, run by astro sync) and one read path (getCollection/getEntry, resolved with render). Nothing reads collection files directly at request time; by the time a page runs, the loader has already turned raw content into validated entries sitting in a content store, and querying is just reading from that store. That’s why a custom loader can pull from a database or an API with the exact same getCollection('blog') call a Markdown-backed collection uses — the query side never needs to know what the loader side did.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
A loader in front of every collection |
Same query API for local files, a data file, or a remote source | One more concept to learn before your first collection works — no more “just drop a file in src/content/” |
A custom loader (store/parseData/generateDigest) |
Full control — pagination, incremental sync via meta, any data shape |
You own the load logic; glob/file cover the common cases with zero code |
render(entry) as a standalone function |
Entries stay plain, serializable objects you can pass as props |
One more import, and old tutorials/AI output showing entry.render() are wrong on Astro 6+ |
Generated types (CollectionEntry<'blog'>) |
Autocomplete and type-checking with zero hand-written interfaces | Types lag behind content.config.ts until astro sync runs |
Common Misconceptions
Section titled “Common Misconceptions”- “Collections only work for local Markdown files.” The
loaderis the whole point —glob/filehappen to read local files, but a loader can call an API, query a database, or generate entries in memory (like thenotesLoaderabove). The query API (getCollection,getEntry,render) is identical either way. - “
entry.render()still works, it’s just old-fashioned.” It was deprecated in Astro 5 and is gone in Astro 6/7 — calling it throwsTypeError: post.render is not a functionat runtime, proven above. Use the importedrender(entry)function. - “I need to hand-write a
Postinterface forentry.data.” You don’t —astro syncgeneratesCollectionEntry<'blog'>(and theInferEntrySchemait’s built from) directly from the Zod schema. A hand-written interface is one more place for the shape to drift out of sync. - “
getCollectionreads the filesystem every time you call it.” It reads from the in-memory content store that the loader already populated duringsync. CallinggetCollectionten times in one build does not re-runglobor hit the network ten times.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet defines or queries a content collection, check for these before trusting it:
- Does every
defineCollectioncall include aloader? Code that drops Markdown files intosrc/content/<name>/with noloaderat all is Astro 5-and-earlier syntax that Astro 6/7 rejects. - Does it call
entry.render()as a method, instead of importingrenderfromastro:contentand callingrender(entry)? The method form throws at runtime — it isn’t just a style preference. - If a custom loader is hand-written, does it call
store.clear()before repopulating on everyload()run, or will stale entries from a previous run stick around? - Is
zimported fromastro/zod(the current, correct source) rather than fromastro:content(the old, now-removed re-export)? - Does a
reference()field get resolved withgetEntry(...)before its data is used, or does the code try to read.data.namestraight off the reference (which is just an id, not the resolved entry)?
🐛 Spot the bug
---import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'hello-world');if (!post) throw new Error('Post not found');
const { Content } = await post.render();---<article><h1>{post.data.title}</h1><Content /></article>Show the answer
post.render() is called as a method on the entry object. Entries returned by getEntry/getCollection are plain data — render only exists as a standalone function imported from astro:content, since Astro 5 deprecated the method form and Astro 6 removed it. Proven in the probe on Astro 7.3.5: swapping the fixed version’s render(post) for post.render() fails the build with TypeError: post.render is not a function thrown from inside the rendered page, not a type error caught ahead of time. The fix is import { getEntry, render } from 'astro:content' and const { Content } = await render(post);.