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 5 reads collection definitions from src/content.config.ts (previously src/content/config.ts in Astro 4). The Astro 5 Content Layer API 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.

// 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 still works behind a legacy.collections flag for backward compatibility, but it is deprecated and removed in Astro 6. Use the loader form for new collections. 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.

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.

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.

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.