Skip to content
Astro as content 4 min read

Content Collections

Most content-driven sites accumulate dozens or hundreds of Markdown files, plus JSON or YAML data that drives navigation, authors, or product listings. Content collections are Astro’s first-class way to organize, validate, and query that content with full TypeScript safety. Instead of globbing files by hand and hoping every frontmatter field exists, you declare a schema once and Astro guarantees that every entry conforms to it — turning a missing pubDate or a typo in draft into a build-time error rather than a runtime surprise.

What a collection is

A collection is a named group of related entries that share a common shape. A blog collection might hold Markdown posts, an authors collection might be a single JSON file of structured data, and a docs collection might mix Markdown and MDX. Each collection has:

  • A loader that tells Astro where the entries come from (local files, a remote API, a CMS).
  • A schema that validates and types the data attached to each entry.

Collections are defined in a single config file, src/content.config.ts (Astro 5) — older projects used src/content/config.ts. Astro reads this file at startup, validates every entry, and generates types so your editor autocompletes fields and flags mistakes.

Defining a collection

You define collections with defineCollection, give each a loader, and export a collections object. Schemas are written with Zod, re-exported by Astro as z.

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

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(),
    draft: z.boolean().default(false),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

The glob loader scans the base directory for files matching pattern. Every file’s frontmatter is parsed and validated against schema. If a post is missing title or sets pubDate to something Zod can’t coerce into a date, the build fails with a precise message naming the file and field.

Astro 5 introduced the Content Layer API, which makes loaders pluggable. The built-in glob and file loaders cover local content, while custom loaders can pull from any data source. The legacy type: "content" / type: "data" syntax still works but is deprecated.

Querying entries

Once collections are defined, you read them with two async helpers from astro:content: getCollection returns every entry (optionally filtered), and getEntry returns a single one by collection and id.

---
// 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());
---

<ul>
  {posts.map((post) => (
    <li>
      <a href={`/blog/${post.id}`}>{post.data.title}</a>
      <time datetime={post.data.pubDate.toISOString()}>
        {post.data.pubDate.toLocaleDateString()}
      </time>
    </li>
  ))}
</ul>

Because the schema is typed, post.data.title autocompletes and post.data.pubDate is a real Date. The filter callback runs at build time, so draft posts never reach the rendered HTML — and since this is a static page, it ships zero JavaScript by default.

Rendering an entry’s body

Markdown and MDX entries carry a render() method that compiles the body into a component you mount with the <Content /> tag.

---
// 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 } = await render(post);
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

Loaders at a glance

LoaderUse it forSource
glob()Many files, one entry eachMarkdown, MDX, JSON, YAML on disk
file()One file containing many entriesA single JSON/YAML array
Custom loaderRemote dataCMS, API, database

A custom loader is just an object with a load() method that calls store.set() for each entry — ideal for pulling posts from a headless CMS while keeping the same typed query API.

Best practices

  • Keep one content.config.ts per project and colocate all collection definitions there for a single source of truth.
  • Always provide a schema, even a minimal one — it is your contract and your autocomplete.
  • Use z.coerce.date() for date fields so string frontmatter parses cleanly into Date objects.
  • Give optional fields sensible .default() values to avoid undefined checks in templates.
  • Filter drafts inside getCollection rather than in the template, so they never ship to production.
  • Prefer the glob/file loaders for local content and reach for custom loaders only when data lives elsewhere.
  • Reference fields with reference() to link collections (for example, a post to its author) and keep relationships type-safe.
Last updated June 14, 2026
Was this helpful?