
What are content collections in Astro?
Content collections are Astro's built-in system for managing groups of related content, such as blog posts, documentation pages, authors or products. You define each collection in src/content.config.ts with a loader, which says where the content comes from, and an optional schema, which says what shape every entry must have. Astro then loads the content, validates it, generates TypeScript types for it and gives you query functions like getCollection() and getEntry() to use it in your pages. The content can live in local Markdown, MDX, JSON, YAML or TOML files, or come from a remote API or CMS through a custom loader.
Collections have changed a lot over the last few major versions. The old src/content/config.ts file, entry.slug and entry.render() are gone, and since Astro 6 every collection needs a loader. In this article you'll learn what a collection actually is, the two kinds of collections Astro offers, how to define your first one, how to query and render entries, how IDs work, and when a collection is the wrong tool. Everything here is written for Astro 7.4.
What a Content Collection Actually Is
A collection is a set of entries that share the same structure. A folder of blog posts is a collection. A JSON file listing your authors is another. Each item inside is called an entry, and each entry has:
- id: a unique, URL-friendly identifier, usually generated from the file name.
- data: the frontmatter (or JSON fields), validated against your schema and fully typed.
- body: the raw, uncompiled Markdown or MDX body, if the entry has one.
- rendered: the rendered HTML and metadata, for entries the loader has already rendered.
- filePath: the path of the source file, for local entries.
Here's a typical layout for a blog with two collections:
src/
├── content.config.ts ← defines every collection
├── content/
│ └── blog/ ← the "blog" collection
│ ├── first-post.md
│ ├── second-post.md
│ └── mdx-post.mdx
└── data/
└── authors.json ← the "authors" collection, one file
The folder names don't matter to Astro. Since the Content Layer API arrived, content doesn't have to live in src/content/ at all. The loader you configure decides where Astro looks.
Why Not Just Import the Files?
You could read Markdown with import.meta.glob() instead, and for a handful of pages that's fine. Collections give you several things on top:
- Validation: a post with a missing title or a misspelt category fails the build with a clear error, instead of rendering a broken page.
- Types:
post.data.pubDateis a realDateand your editor knows it, with autocomplete for every field. - A single API: local files, a CMS and a REST API are all queried the same way.
- Caching and scale: build-time collections are stored in a data store that persists between builds, which keeps large sites (tens of thousands of entries) manageable.
- Relationships: entries can reference entries in other collections, such as a post referencing its author.
The Two Kinds of Collections
Astro now has two types of collection, and it's worth knowing which one you're using before you write any code.
| Build-time collections | Live collections | |
|---|---|---|
| Config file | src/content.config.ts | src/live.config.ts |
| Define with | defineCollection() | defineLiveCollection() |
| Query with | getCollection(), getEntry() | getLiveCollection(), getLiveEntry() |
| When data is fetched | At build time (or content sync in dev) | On every request |
| Built-in loaders | glob() and file() | None, you write or install one |
| MDX and image optimisation | Supported | Not supported |
| Needs an adapter | No | Yes, for on-demand rendering |
Build-time collections are the default and the right choice for most content: blog posts, docs, product descriptions and anything that changes when you publish rather than every second. The data is loaded once, stored, and reused.
Live collections fetch fresh data at request time. They're for things like stock levels, prices or CMS previews, where waiting for a rebuild isn't acceptable. They became stable in Astro 6 and gained cache hints in Astro 7, but they cost a request to your data source each time a page renders (unless you cache), and they can't process MDX or optimise images.
You can use both in the same project. The rest of this article focuses on build-time collections, because that's what most blogs and content sites need.
Defining Your First Collection
Collections are defined in one file, src/content.config.ts (.js and .mjs also work). You create each collection with defineCollection() and export them all in a single collections object:
// 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({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
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(),
email: z.email(),
}),
});
export const collections = { blog, authors };
Three imports do all the work:
- defineCollection from
astro:contentregisters the collection and gives you types. - glob and file from
astro/loadersare the two built-in loaders. - z from
astro/zodis Astro's re-export of Zod 4. Import it from here rather than installing Zod yourself, so your schema uses the same version Astro does.
A Markdown entry in that blog collection looks like this:
---
title: "My first post"
description: "A short introduction to the blog and what I plan to write about."
pubDate: 2026-09-01
tags: ["astro", "meta"]
---
## Hello
This is my first post.
The glob() Loader
glob() creates one entry per file. It needs a pattern (using micromatch glob syntax) and usually a base directory to resolve it from. It supports Markdown, MDX, Markdoc, JSON, YAML and TOML files. It also accepts a few optional settings:
- generateId: a function that returns a custom ID for each entry.
- retainBody: set to
falseto stop storing the raw body, which shrinks the data store for big collections.entry.bodywill then beundefined. - deferRender: new in Astro 7.1. When
true, Markdown entries are rendered when a page renders them rather than during content sync, which keeps memory use down on very large collections.
The file() Loader
file() creates many entries from a single file. JSON and YAML files should contain an array of objects, each with a unique id, or an object whose keys are the IDs:
[
{ "id": "sajjad", "name": "Sajjad Hossain", "email": "hello@example.com" },
{ "id": "ada", "name": "Ada Lovelace", "email": "ada@example.com" }
]
Unlike glob(), file() won't generate IDs for you. Each object must have one. For formats Astro doesn't parse natively, such as CSV, you pass a parser function as the second argument.
What the Schema Does
The schema is optional, but it's where most of the value comes from. Every entry is validated against it when content is synced, and Astro generates TypeScript types from it. I'll go deep on schemas in how to define a content collection schema with Zod, but the short version is that z.coerce.date() turns a frontmatter date into a Date, .optional() allows a field to be missing, and .default() fills it in when it is.
Querying and Rendering Entries
Once a collection is defined, any .astro file can query it. There are two main functions:
- getCollection(name, filter?) returns an array of entries, optionally filtered.
- getEntry(name, id) returns a single entry, or
undefinedif it doesn't exist.
---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
))}
</ul>
Notice the sort. The order getCollection() returns is not guaranteed and can differ between platforms, so always sort when order matters. Filtering, sorting, grouping and limiting are covered in detail in how to query and sort content collections.
Rendering the Body
To turn a Markdown or MDX entry into HTML, pass it to the render() function from astro:content. It returns a <Content /> component, the list of headings, and any frontmatter added by Markdown plugins:
---
// src/pages/blog/[...id].astro
import { getCollection, render } from "astro:content";
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);
---
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString("en-GB")}
</time>
<Content />
</article>
Collections don't create routes on their own. You always write a page like this one that turns entries into URLs. For a static site that means getStaticPaths(), which is explained fully in the post on dynamic routes with getStaticPaths. The file is named [...id].astro (a rest parameter) rather than [id].astro because IDs can contain slashes, as you'll see next.
Typing Components That Receive Entries
When you pass an entry to a component, use the CollectionEntry type so the props match your schema:
---
// src/components/PostCard.astro
import type { CollectionEntry } from "astro:content";
interface Props {
post: CollectionEntry<"blog">;
}
const { post } = Astro.props;
---
<a href={`/blog/${post.id}/`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
</a>
If you rename a field in the schema, every component using it shows a type error straight away.
How Entry IDs Work
With glob(), each entry's id is generated from its file path relative to base, slugified with github-slugger. That means lowercase, with spaces turned into hyphens. In a test project, these files produced these IDs:
| File | Generated id |
|---|---|
first-post.md | first-post |
2025/Year-In-Review.md | 2025/year-in-review |
mdx-post.mdx | mdx-post |
Files in subfolders keep the folder in their ID, which is why the route above uses [...id]. You can override the ID of a single entry by adding a slug field to its frontmatter:
---
title: "Custom slug"
slug: notes/custom-id
---
That entry's id becomes notes/custom-id. If you were on Astro 4 or earlier, note the change: there is no separate entry.slug any more. The id is the value you use in URLs and in getEntry().
Where Collections Fit in the Build
It helps to know when things happen:
- Content sync: when you run
astro dev,astro buildorastro sync, Astro runs every collection's loader, validates the data and writes it to a data store. In my test project that store lives atnode_modules/.astro/data-store.json. - Type generation: Astro writes types for your collections into the
.astro/folder, which is whyastro:contentknows about your fields. - Rendering: pages call
getCollection(),getEntry()andrender(), which read from the store rather than from your files directly.
If you change your schema and your editor doesn't pick it up, run npx astro sync, or press s then Enter in the dev server terminal, to sync content and regenerate types.
Because the store persists, Astro can skip unchanged entries on the next build. When the content config itself changes, Astro clears the store and loads everything again. You'll see that in the terminal:
[content] Syncing content
[content] Content config changed
[content] Clearing content store
[content] Synced content
Loading Content From Anywhere
The loader is what makes collections flexible. Besides glob() and file(), you can:
- Write an inline function that fetches data and returns an array of objects with
idfields. - Write a loader object with a
load()method that has full access to the data store, a logger, Markdown rendering and persistent metadata. - Install a community loader for a CMS, Obsidian vault, GitHub repo or other source.
Here's the smallest possible remote collection, an inline loader that fetches users from an API at build time:
// src/content.config.ts (excerpt)
const users = defineCollection({
loader: async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const users: Array<{ id: number; name: string; email: string }> =
await response.json();
return users.map((user) => ({
id: String(user.id),
name: user.name,
email: user.email,
}));
},
schema: z.object({ name: z.string(), email: z.email() }),
});
Those users can now be queried with getCollection("users") exactly like local Markdown. The full loader API is covered in how to use the Content Layer API.
When Not to Use a Collection
Collections are excellent for repeated, structured content. They're overkill or a poor fit when:
- You have one or two pages: an
about.astropage with its content written straight into it is simpler. - The files aren't processed by Astro: PDFs, downloads and images you serve as-is belong in
public/. - A service's own SDK fits better: if a data source has a client library you prefer and no loader, calling it directly is fine.
- Data must be fresh on every request: use a live collection or plain server-side fetching instead of a build-time collection.
Upgrading From Older Collections
If you're coming from Astro 4 or a project that was never migrated, these are the changes that will bite:
- Config file location:
src/content/config.tsis gone. Usesrc/content.config.ts. - Loaders are required: the old
type: 'content'andtype: 'data'collections were removed in Astro 6. Useglob()orfile(). - slug is now id: replace
entry.slugwithentry.ideverywhere, includinggetStaticPaths(). - render() is a function: replace
entry.render()withrender(entry), imported fromastro:content. - getEntryBySlug() and getDataEntryById() are removed: use
getEntry(). - Zod comes from astro/zod: and it's Zod 4, so some APIs changed, such as
z.email()instead ofz.string().email()anderrorinstead ofmessagefor custom errors.
FAQ: Content Collections in Astro
In a single src/content.config.ts file at the root of src. Each collection is created with defineCollection() and all of them are exported together in a collections object. Live collections use a separate src/live.config.ts file.
No. The loader decides where content comes from. The glob() loader takes a base path, so your posts can live in src/content/blog, src/data/posts or anywhere else in the project.
No, but it's strongly recommended. Without a schema, entry data is typed as any and nothing is validated. With one, bad frontmatter fails the build and every field gets accurate TypeScript types.
getCollection() doesn't guarantee any order, and the order can vary between operating systems. Sort the returned array yourself, for example by comparing pubDate values, whenever order matters.
It was removed along with the legacy collections API. Every entry now has an id, generated from the file path by the glob() loader or set with a slug field in frontmatter. Use entry.id in your URLs and getEntry() calls.
Yes. Write a custom loader, or install a community loader for your CMS, and the CMS content becomes a normal collection you query with getCollection(). If the content must be fresh on every request, use a live collection instead.
Conclusion
Content collections give your content a defined shape and a single, typed way to load it. You declare each collection once in src/content.config.ts with a loader and a schema, and Astro handles loading, validation, type generation and caching. From there, getCollection(), getEntry() and render() cover nearly everything a blog or documentation site needs, with id as the stable key for URLs.
Start with glob() for Markdown and file() for small data sets, add a schema early so mistakes surface at build time, and remember to sort your queries. When your content outgrows local files, the same API stretches to remote sources through custom loaders, and to request-time data through live collections, without changing how your pages consume it.


