React

Astro content collections: schemas, types and queries

A collection is a Zod schema plus a loader: Astro validates files at build time, generates the types and hands you getCollection.

Key points

  • A collection is a schema plus a loader, not a special folder.
  • The config file in Astro 5 is src/content.config.ts.
  • render(entry) replaces entry.render(), and id replaces slug.

An Astro content collection is a folder of files plus a Zod schema: the loader reads the files, the schema validates them at build time and getCollection() returns typed objects. No frontmatter left to chance.

A config file, not a magic folder

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

export const collections = { blog };

The file goes in src/content.config.ts. Astro 4 used src/content/config.ts: the new location exists because a collection can also read files outside src/content.

Reading a collection in a page

---
import { getCollection, render } from "astro:content";

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


  {posts.map(async (post) => {
    const { Content } = await render(post);
    return {post.data.title};
  })}

The second argument of getCollection is an in-memory filter, not a query: the collection stays typed and the filter receives validated data. For a single entry use getEntry("blog", id).

One dynamic route per entry

---
import type { GetStaticPaths } from "astro";
import { getCollection, render } from "astro:content";

export const getStaticPaths = (async () => {
  const posts = await getCollection("blog", ({ data }) => !data.draft);
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}) satisfies GetStaticPaths;

const { post } = Astro.props;
const { Content } = await render(post);
---

The [id].astro file calls getStaticPaths and returns one entry per published post: the build writes a page for each, with no runtime query. A collection of 638 files produces 638 pages, and every page receives the already validated entry in Astro.props.

Where the constraints live

FieldSchemaWhat it prevents
publishedAtz.coerce.date()A date written as a string that stays a string
draftz.boolean().default(false)A post without the field breaking the build
coverz.string().startsWith('/')An external path that breaks the images
authorreference('authors')A reference to an author who does not exist

Validation runs when the loader reads the files: on build and in the dev server. It is not a runtime check, so a response fetched from an API never passes through it.

node -e "const fs=require('fs');const d='data/blog';let n=0,b=0;for(const f of fs.readdirSync(d)){if(!f.endsWith('.json')||f[0]==='.')continue;n++;b+=JSON.parse(fs.readFileSync(d+'/'+f,'utf8')).body.length;}console.log(n+' files, '+(b/n).toFixed(2)+' blocks each')"

The errors you actually see

  • astro sync skipped: your editor does not know the types and flags every entry.data as any.
  • The slug key used after the upgrade to Astro 5: the field is now called id.
  • entry.render() in an Astro 5 page: the function is render(entry), imported from astro:content.
  • Schema left in src/content/config.ts with the new loader: the file is never read.
  • Reading files with fs inside the page: it works on the laptop and breaks on the host, where src/ does not exist at runtime.

The decision rule

Rule: if the content lives in the repository and changes with a commit, glob() is the choice; if it changes without a deploy, such as a price or a stock level, you need a loader that reads from an API and the collection stays the place for the schema. The signal is frequency: more edits per day than deploys per day moves the boundary.

The schema grows with the content. Once the fields pass ten, pull the shared objects, seo, cover, author, into Zod variables and reuse them across collections: one change to the alt field touches a single place instead of four.

The known limitation

A collection is not a CMS. The glob() loader reads files inside the repository, so every content change needs a deploy. For content that changes hourly you need a remote loader or an API route; the collection remains the home of the schema, not of the writing.

Commands

npx astro sync
npx astro check

From the catalogue: the Astro Association components, the Astro logo block and glass cards. The guides: the missing client directive and view transitions in Astro; the comparison: Astro against Next.js for documentation.

Frequently asked questions

Where does the config file go in Astro 5?

In src/content.config.ts. Astro 4 used src/content/config.ts: that path still works, but the new one is what the docs describe.

Can I use collections without Zod?

Yes, the schema is optional. Without it you lose validation and generated types, so keep at least the fields your template reads.

Do collections work with MDX?

Yes. The loader glob pattern must include mdx, otherwise those files stay outside the collection.

How do I filter out unpublished posts?

Add a boolean field to the schema, for example draft with a default of false, and filter inside getCollection.

A collection or an API route?

A collection for files versioned in the repository, an API route for content that changes without a deploy. The schema matters in both cases.