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

Astro 5 reads collection definitions from src/content.config.ts (previously src/content/config.ts in Astro 4). Every collection needs a type and a Zod schema.

// src/content.config.ts
import { defineCollection, z } from "astro:content";
 
const posts = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    slug: 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 };

Keep schemas strict. Use z.string() with .min() or .max() where the field has real constraints. Use 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.

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 by slug, use getEntry("posts", slug) and handle the undefined case explicitly.

Use type: "data" for non-content JSON and YAML

Collections are not limited to markdown. Set type: "data" for JSON or YAML files: a collection of product SKUs, author profiles, navigation config, or site settings.

const authors = defineCollection({
  type: "data",
  schema: z.object({
    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.

import { defineCollection, reference, z } from "astro:content";
 
const posts = defineCollection({
  type: "content",
  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.