komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Content & MDX••By Komal Nakrani

Astro Content Collections: The Complete Guide

Astro content collections turn Markdown, MDX, JSON or API data into typed, validated entries. Loaders, Zod schemas, queries and rendering in Astro 7.

#Astro#Content Collections#Content Layer#MDX#Zod

Astro content collections are named groups of content (Markdown, MDX, JSON, YAML, or data from an API) that Astro loads, validates against a schema and exposes through typed query functions. You define them in src/content.config.ts with a loader and a Zod schema, then read them with getCollection() and getEntry(). A post with a missing title or a malformed date fails the build instead of shipping a broken page.

This guide covers the Content Layer API as it works in Astro 7: loaders, schemas, queries, rendering, references between collections, and the errors you’re most likely to hit. Every snippet is copy-paste ready.

Key Takeaways

  • Collections live in src/content.config.ts. Each one needs a loader, usually glob() for folders of files or file() for a single JSON/YAML file.
  • Schemas use Zod. Import z from astro/zod; the astro:content re-export is scheduled for removal in Astro 8.
  • Entries have an id (derived from the file path for glob()) and a data object. Render Markdown with render(entry) from astro:content.
  • reference() links entries across collections, such as a post to its author, and is validated at build time.
  • Custom loaders pull from a CMS or API into the same typed getCollection() call.

What a content collection is

Without collections, a Markdown blog is a folder of files that you import and hope are consistent. With collections, Astro reads every file, parses frontmatter, checks it against your schema, generates TypeScript types, and stores the result in a data layer that pages query.

Three things come with that:

  1. Validation. The build stops on bad data and names the file and field.
  2. Types. post.data.pubDate is a Date in your editor, with autocomplete.
  3. One API for every source. Local Markdown, a JSON file and a headless CMS are all queried with the same functions.

Collections are optional. A site with three hand-written pages doesn’t need them. Anything with repeated content (posts, case studies, team members, products, docs pages, changelog entries) does.

Defining collections in src/content.config.ts

Create src/content.config.ts at the root of src/:

// 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({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
  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(),
    url: z.url().optional(),
  }),
});

export const collections = { blog, authors };

The key in the exported collections object becomes the collection name you query.

The glob() loader

glob() loads one entry per file. pattern is a glob relative to base. The pattern above, **/[^_]*.{md,mdx}, matches Markdown and MDX in any subfolder but skips files starting with an underscore, which is a handy way to keep drafts or partials out.

The entry id is the file path relative to base, without the extension and slugified. So src/content/blog/guides/first-post.md gets the id guides/first-post. To control it, set a slug field in that file’s frontmatter, or pass generateId to the loader.

The file() loader

file() loads many entries from one JSON or YAML file. The file must be an array of objects with an id field, or an object whose keys are ids:

[
  { "id": "komal", "name": "Komal Nakrani", "url": "https://komalnakrani.com/" },
  { "id": "guest", "name": "Guest Author" }
]

Use it for authors, categories, navigation, FAQs, anything small and structured.

Schemas with Zod

The schema describes what each entry’s data must look like. Astro 7 ships Zod 4, so the full Zod API is available.

Useful patterns for content:

schema: z.object({
  title: z.string().max(70),
  pubDate: z.coerce.date(),              // accepts "2026-10-28" strings
  status: z.enum(['draft', 'review', 'live']).default('draft'),
  tags: z.array(z.string()).default([]),
  rating: z.number().min(1).max(5).optional(),
  canonical: z.url().optional(),
})
  • z.coerce.date() converts frontmatter strings to Date objects.
  • .default() fills in missing values, so templates never see undefined for that field.
  • z.enum() catches typos in status or category fields.

Images in frontmatter

To validate and optimise a local image referenced in frontmatter, pass a function to schema and use the image() helper:

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: ({ image }) => z.object({
    title: z.string(),
    cover: image(),
    coverAlt: z.string(),
  }),
});

In the Markdown file, cover: ./cover.jpg is resolved relative to that file. In a template, post.data.cover is an image object you pass straight to <Image src={post.data.cover} alt={post.data.coverAlt} />. A missing file fails the build.

Querying collections

Import the query functions from astro:content.

getCollection()

Returns every entry, optionally filtered:

---
import { getCollection } from 'astro:content';

const posts = (await getCollection('blog', ({ data }) => !data.draft))
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<ul>
  {posts.map((post) => (
    <li>
      <a href={`/blog/${post.id}/`}>{post.data.title}</a>
      <time datetime={post.data.pubDate.toISOString()}>
        {post.data.pubDate.toLocaleDateString('en-US', { dateStyle: 'medium' })}
      </time>
    </li>
  ))}
</ul>

getCollection() doesn’t guarantee order, so always sort explicitly.

A common filter shows drafts in development and hides them in production:

const posts = await getCollection('blog', ({ data }) =>
  import.meta.env.PROD ? !data.draft : true,
);

getEntry() and getEntries()

Fetch one entry by collection and id:

import { getEntry } from 'astro:content';
const author = await getEntry('authors', 'komal');

It returns undefined if the id doesn’t exist, so check before using it. getEntries() resolves an array of references in one call.

Rendering Markdown and MDX

Entries from Markdown or MDX files have a body and can be rendered. Import render from astro:content and call it with the entry:

---
// src/pages/blog/[...id].astro
import { getCollection, render } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';

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);
---
<BlogLayout title={post.data.title}>
  <h1>{post.data.title}</h1>
  <Content />
</BlogLayout>

The rest parameter [...id] matters when ids contain slashes (from subfolders). If every post sits at the top level, [id].astro works too.

render() returns three things:

  • Content, a component that renders the body.
  • headings, an array of { depth, slug, text } for every heading, which is everything you need for a table of contents.
  • remarkPluginFrontmatter, any frontmatter added by remark plugins, which is how you add reading time to posts.

For MDX entries, you can map Markdown elements to your own components with <Content components={{ h2: MyHeading }} />.

Blog themes with collections already set up

Every Komal blog and editorial theme ships with its collections, schemas and post pages wired up. If you want to start writing today, browse Astro blog themes.

References between collections

reference() declares that a field points at an entry in another collection:

import { defineCollection, reference } from 'astro:content';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    author: reference('authors'),
    related: z.array(reference('blog')).default([]),
  }),
});

In frontmatter you write the id:

author: komal
related:
  - astro-routing-guide

Astro checks that komal exists in authors at build time. In a template, resolve it:

const author = await getEntry(post.data.author);
const related = await getEntries(post.data.related);

This replaces a lot of hand-rolled lookup code. For an automatic version based on shared tags, see related posts without a plugin.

Building a blog index and post pages

A complete minimal blog needs three files: the config above, an index page and the dynamic post page.

---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';

const posts = (await getCollection('blog', ({ data }) => !data.draft))
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title="Blog" description="Notes on building with Astro.">
  <h1>Blog</h1>
  {posts.map((post) => (
    <article>
      <h2><a href={`/blog/${post.id}/`}>{post.data.title}</a></h2>
      <p>{post.data.description}</p>
    </article>
  ))}
</BaseLayout>

Add tag pages by collecting unique tags in getStaticPaths(). When the list gets long, split it with paginate(); the Astro routing guide shows both.

Custom loaders for CMS and API data

A loader is a function that fetches data and stores entries. The simplest form is an async function that returns an array of objects with id:

// src/content.config.ts
const releases = defineCollection({
  loader: async () => {
    const res = await fetch('https://api.github.com/repos/withastro/astro/releases?per_page=10');
    const data = await res.json();
    return data.map((r: any) => ({
      id: String(r.id),
      name: r.name,
      publishedAt: r.published_at,
      url: r.html_url,
    }));
  },
  schema: z.object({
    name: z.string(),
    publishedAt: z.coerce.date(),
    url: z.url(),
  }),
});

This runs at build time. For larger sources, an object loader with a load() method can cache, store a digest per entry, and update incrementally between builds. Most headless CMSs publish a loader package you install and drop into loader:. Our headless CMS guide compares the options.

Live collections

Regular collections load at build time (or at server start). When data has to be fresh on every request, such as stock levels or prices, Astro supports live collections defined with defineLiveCollection() in src/live.config.ts and queried with getLiveCollection() and getLiveEntry() on on-demand pages. They need an adapter and return errors as values rather than throwing, so you handle a failed fetch in the page. Use them only where build-time data is genuinely too stale.

Common errors and how to fix them

“… frontmatter does not match collection schema.” The message names the file and field. Fix the frontmatter, or loosen the schema with .optional() or .default().

post.slug is undefined. Astro 5 replaced slug with id. Use post.id in links and getStaticPaths().

post.render is not a function. Rendering moved to a standalone function: import { render } from 'astro:content' and call render(post).

Collection is empty. Check base is relative to the project root (./src/content/blog) and that the pattern matches your file extensions. Restart the dev server after adding a new collection.

Types are stale. Run npx astro sync to regenerate types after schema changes, or restart astro dev.

Config file ignored. It must be src/content.config.ts. The old src/content/config.ts location only works behind the temporary legacy.collectionsBackwardsCompat flag.

The content collections reference lists every option, and the astro:content module reference documents each function’s signature.

Worked example: a case studies collection

Blogs are the usual example. Here’s a different one: an agency’s case studies, with a client reference, a cover image, results and a featured flag.

// src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';

const clients = defineCollection({
  loader: file('src/data/clients.json'),
  schema: z.object({
    name: z.string(),
    industry: z.enum(['saas', 'retail', 'health', 'finance', 'other']),
  }),
});

const caseStudies = defineCollection({
  loader: glob({ pattern: '*.md', base: './src/content/case-studies' }),
  schema: ({ image }) => z.object({
    title: z.string(),
    client: reference('clients'),
    summary: z.string().max(200),
    cover: image(),
    coverAlt: z.string(),
    results: z.array(z.object({ label: z.string(), value: z.string() })).min(1),
    featured: z.boolean().default(false),
    order: z.number().default(100),
  }),
});

export const collections = { clients, caseStudies };

One entry:

---
title: "Rebuilding a checkout in six weeks"
client: northwind
summary: "A headless storefront for a 40-year-old retailer, shipped before peak season."
cover: ./northwind-cover.jpg
coverAlt: "Northwind product page on a phone"
results:
  - { label: "Build time", value: "6 weeks" }
  - { label: "Pages migrated", value: "1,200" }
featured: true
order: 1
---

The brief arrived in August...

A home page section that shows featured work, with the client name resolved:

---
import { getCollection, getEntry } from 'astro:content';
import { Image } from 'astro:assets';

const featured = (await getCollection('caseStudies', ({ data }) => data.featured))
  .sort((a, b) => a.data.order - b.data.order)
  .slice(0, 3);

const items = await Promise.all(
  featured.map(async (study) => ({
    study,
    client: await getEntry(study.data.client),
  })),
);
---
<section>
  {items.map(({ study, client }) => (
    <a href={`/work/${study.id}/`} class="case">
      <Image src={study.data.cover} alt={study.data.coverAlt} width={640} />
      <p class="client">{client?.data.name}</p>
      <h3>{study.data.title}</h3>
    </a>
  ))}
</section>

What the schema buys you here: a typo in client fails the build, a case study without results fails the build, a missing cover image fails the build, and order gives editors control of the sequence without touching code. That’s the difference between “a folder of Markdown” and content you can trust.

Migrating from Astro 4 collections

If you’re upgrading an older project, these are the changes, in the order you’ll hit them:

  1. Move the config. Rename src/content/config.ts to src/content.config.ts.
  2. Add loaders. Replace type: 'content' with loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), and type: 'data' with glob() for folders of JSON/YAML or file() for single files.
  3. Swap slug for id. Update getStaticPaths(), links and getEntry() calls. If you relied on custom slugs in frontmatter, the glob() loader uses a slug frontmatter field as the id when present.
  4. Swap entry.render() for render(entry). Import render from astro:content.
  5. Import z from astro/zod.
  6. Run npx astro sync and fix the type errors it surfaces.

If you can’t migrate everything at once, legacy.collectionsBackwardsCompat: true in config keeps the old behaviour temporarily. Treat it as a bridge, not a destination.

How collections are organised in a production theme

In Komal themes, collections are set up so editing is mostly writing Markdown:

  • Content sits in src/content/<collection>/, one file per entry.
  • The schema in src/content.config.ts documents every field a writer can set.
  • Pages under src/pages/ only query and render. They hold no content.

The content docs walk through adding a post to a theme step by step.

FAQ

Where do I put content collections in Astro 7?

Define them in src/content.config.ts. The content files themselves can live anywhere; the base option of glob() points at the folder. src/content/ is the convention.

What is the difference between glob() and file()?

glob() creates one entry per file in a folder, which suits posts and pages. file() creates many entries from a single JSON or YAML file, which suits authors, categories or settings.

Can I use content collections with MDX?

Yes. Install the MDX integration with npx astro add mdx and include mdx in your glob() pattern. MDX entries render with render() like Markdown, and can import components.

Do content collections work with on-demand rendering?

Yes. Build-time collections are available on on-demand pages. For data that must be fetched per request, use live collections.

How do I sort a collection by date?

getCollection() returns entries in no guaranteed order. Sort the result: posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()).