---
title: "Astro: Content Collections"
slug: "astro-content-collections"
category: "frontend"
tags: ["frontend", "astro", "content-collections", "zod", "mdx"]
status: "stable"
last_updated: 2026-08-14
summary: "Define Astro Content Layer collections with a glob loader and Zod schema, query with getCollection/getEntry, and use collection references for typed relations."
related: ["[[frontend/astro]]", "[[frontend/astro-mdx]]", "[[frontend/astro-ssr]]", "[[comparisons/nextjs-vs-astro]]", "[[seo/technical]]", "[[frontend/astro-view-transitions]]"]
---

## Overview

Content Collections are Astro's typed content layer. You define a Zod schema for each collection, Astro validates every file against it at build time, and your queries return fully typed objects. The build fails on missing or malformed frontmatter, which catches data bugs at the earliest possible moment. Use Content Collections for any folder of markdown or MDX files with shared structure; use file-based routes in `src/pages/` only for one-off pages that do not share a schema with other pages.

## Define collections with a `loader` in `src/content.config.ts`

Astro reads collection definitions from `src/content.config.ts` (previously `src/content/config.ts` in Astro 4). The Content Layer API, introduced in Astro 5, replaces the legacy `type: "content"` property with a `loader`. Use `glob()` for a folder of files and `file()` for a single data file; both come from `astro/loaders`. Each collection still takes a Zod `schema`. Astro 6 also requires Zod 4; Zod 3 schemas need `zod/v3` compatibility imports or an upgrade.

```ts
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const posts = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
  schema: z.object({
    title: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    summary: z.string().max(160),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { posts };
```

The legacy `type: "content"` and `type: "data"` form, along with the `legacy.collections` backward-compatibility flag, was removed entirely in Astro 6.0. A project still on the legacy form must migrate to `loader` before upgrading. Keep schemas strict: use `z.string()` with `.min()` or `.max()` where the field has real constraints, and `z.coerce.date()` for dates so YAML date values (which parse as strings) coerce correctly.

## Query with `getCollection` and filter at the query site

`getCollection("posts")` returns all entries in the collection. Filter drafts, sort by date, and apply any business logic here, not in templates.

```ts
import { getCollection } from "astro:content";

const posts = await getCollection("posts", ({ data }) => !data.draft);
const sorted = posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
```

In Astro 5, `getCollection` is available in server endpoints and SSR routes in addition to static `.astro` files. For a single entry, use `getEntry("posts", id)` and handle the `undefined` case explicitly. The Content Layer replaced the old auto-generated `slug` property with `id`; the second argument to `getEntry` is the entry `id`, not a `slug`. If you need a human-facing slug, declare it as an explicit field in the schema and map it yourself.

## Use the `file()` loader for non-content JSON and YAML

Collections are not limited to markdown. Use the `file()` loader for a single JSON or YAML file holding many entries: product SKUs, author profiles, navigation config, or site settings. Use `glob()` with a JSON/YAML pattern when each entry lives in its own file.

```ts
import { file } from "astro/loaders";

const authors = defineCollection({
  loader: file("./src/content/authors.json"),
  schema: z.object({
    id: z.string(),
    name: z.string(),
    bio: z.string().optional(),
    twitter: z.string().optional(),
  }),
});
```

Query data collections with `getCollection("authors")` and access `entry.data.name`. There is no `entry.body` for data collections.

## Use collection references for relational content

Reference one collection from another with `reference()`. Astro resolves the reference and validates that the referenced entry exists.

```ts
import { defineCollection, reference, z } from "astro:content";
import { glob } from "astro/loaders";

const posts = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
  schema: z.object({
    title: z.string(),
    author: reference("authors"),
  }),
});
```

To resolve a referenced entry, call `getEntry(post.data.author)`. This gives you the full typed author object. Use references instead of manually duplicating author data in every post frontmatter.

## Use file-based routes for structurally unique pages

Content Collections are the right choice when multiple pages share a schema. Use file-based routes in `src/pages/` for pages that do not fit a collection schema: a custom contact page, a one-off landing page, a dynamically assembled index page. Mixing both is fine; Astro does not force one model.

A sign that you should convert a folder to a collection: you are manually parsing frontmatter in a `getStaticPaths` call, or you are duplicating TypeScript type definitions that Astro could infer from a schema.

## Validate the schema catches errors at build time

Run `astro build` after adding required fields to a schema. It will fail immediately on files that are missing those fields. This is the primary benefit over raw `import.meta.glob`: validation runs every build, not just when you touch the schema file.

For local development, `astro check` (from `@astrojs/check`) type-checks `.astro` files and surfaces schema violations before the build.

## Related

- [[frontend/astro]]
- [[frontend/astro-mdx]]
- [[frontend/astro-ssr]]
- [[comparisons/nextjs-vs-astro]]
- [[seo/technical]]
- [[frontend/astro-view-transitions]]
