Skip to content

Content Collections

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):

src/content.config.ts
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.

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.

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).

src/lib/notes-loader.ts
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 collection
import { 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 sync
04:54:09 [content] Syncing content
04:54:09 [notes-loader] notes-loader: lastRun=never
04:54:09 [content] Synced content
04:54:09 [types] Generated 1.16s
$ npx astro sync
04:54:19 [content] Syncing content
04:54:19 [notes-loader] notes-loader: lastRun=2026-09-25T21:54:09.825Z
04:54:19 [content] Synced content

Almost 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"]
The content collection pipeline

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 first
const 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 id
const 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.

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 function

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:

src/lib/format-post.ts
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).

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.

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
  • “Collections only work for local Markdown files.” The loader is the whole point — glob/file happen to read local files, but a loader can call an API, query a database, or generate entries in memory (like the notesLoader above). 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 throws TypeError: post.render is not a function at runtime, proven above. Use the imported render(entry) function.
  • “I need to hand-write a Post interface for entry.data.” You don’t — astro sync generates CollectionEntry<'blog'> (and the InferEntrySchema it’s built from) directly from the Zod schema. A hand-written interface is one more place for the shape to drift out of sync.
  • “getCollection reads the filesystem every time you call it.” It reads from the in-memory content store that the loader already populated during sync. Calling getCollection ten times in one build does not re-run glob or hit the network ten times.

When a generated snippet defines or queries a content collection, check for these before trusting it:

  • Does every defineCollection call include a loader? Code that drops Markdown files into src/content/<name>/ with no loader at all is Astro 5-and-earlier syntax that Astro 6/7 rejects.
  • Does it call entry.render() as a method, instead of importing render from astro:content and calling render(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 every load() run, or will stale entries from a previous run stick around?
  • Is z imported from astro/zod (the current, correct source) rather than from astro:content (the old, now-removed re-export)?
  • Does a reference() field get resolved with getEntry(...) before its data is used, or does the code try to read .data.name straight 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);.

Where do you define content collections in Astro 7?
What is the difference between the glob and file loaders?
How do you render a collection entry's Markdown body to HTML?
After a schema validates, what type is a z.coerce.date() frontmatter field on entry.data?
Does this compile and run on Astro 7?
const post = await getEntry('blog', 'hello-world');
if (!post) throw new Error('Post not found');

const { Content } = await post.render();
Where does a custom loader's generateDigest() get used, and why?