Type something to search...
What are content collections in Astro?

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.pubDate is a real Date and 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 collectionsLive collections
Config filesrc/content.config.tssrc/live.config.ts
Define withdefineCollection()defineLiveCollection()
Query withgetCollection(), getEntry()getLiveCollection(), getLiveEntry()
When data is fetchedAt build time (or content sync in dev)On every request
Built-in loadersglob() and file()None, you write or install one
MDX and image optimisationSupportedNot supported
Needs an adapterNoYes, 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:content registers the collection and gives you types.
  • glob and file from astro/loaders are the two built-in loaders.
  • z from astro/zod is 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 false to stop storing the raw body, which shrinks the data store for big collections. entry.body will then be undefined.
  • 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 undefined if 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:

FileGenerated id
first-post.mdfirst-post
2025/Year-In-Review.md2025/year-in-review
mdx-post.mdxmdx-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:

  1. Content sync: when you run astro dev, astro build or astro sync, Astro runs every collection's loader, validates the data and writes it to a data store. In my test project that store lives at node_modules/.astro/data-store.json.
  2. Type generation: Astro writes types for your collections into the .astro/ folder, which is why astro:content knows about your fields.
  3. Rendering: pages call getCollection(), getEntry() and render(), 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 id fields.
  • 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.astro page 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.ts is gone. Use src/content.config.ts.
  • Loaders are required: the old type: 'content' and type: 'data' collections were removed in Astro 6. Use glob() or file().
  • slug is now id: replace entry.slug with entry.id everywhere, including getStaticPaths().
  • render() is a function: replace entry.render() with render(entry), imported from astro: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 of z.string().email() and error instead of message for 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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper