Astro Content Collections: A 30-Minute Getting-Started (Astro 6)

A focused, current walkthrough of Astro Content Collections on Astro 6: the glob() loader, content.config.ts, Zod schemas, render(entry), and the workflow that keeps a 1,000-article site maintainable.

Content Collections turn a folder of Markdown into a typed, queryable database. The catch in 2026: nearly every tutorial you’ll find online still shows the Astro 4 API, which Astro 6 removed. This walkthrough uses the current Content Layer API end to end, so the code actually compiles.

TL;DR

  • On Astro 6 (stable since March 10, 2026; current release line is 6.4), collections are defined with a loader in src/content.config.ts — not the old src/content/config.ts with type: 'content'.
  • Local Markdown/MDX uses the glob() loader; JSON/YAML uses file(). Both come from astro/loaders.
  • entry.slug is gone — use entry.id. entry.render() is gone — import render from astro:content and call render(entry).
  • Zod now imports from astro/zod (Astro 6 ships Zod 4), though the re-export from astro:content still works.
  • Setup takes about 30 minutes once and saves you on every field rename, slug change, or bad-frontmatter bug afterward.

What changed, and why old tutorials break

If you copy a content-collections snippet from a 2024 blog post into a fresh Astro 6 project, it will fail. The Content Layer API landed in Astro 5.0 and the legacy collections API was fully removed in 6.0. Three concrete breakages:

Astro 4 (dead)Astro 5/6 (current)
src/content/config.tssrc/content.config.ts (project root, beside astro.config)
defineCollection({ type: 'content', ... })defineCollection({ loader: glob({ ... }), ... })
entry.slugentry.id
await entry.render()await render(entry) (import from astro:content)
import { z } from 'astro:content'import { z } from 'astro/zod' (re-export still works)

The payoff for the rewrite is real: Astro’s own numbers put Markdown builds up to 5x faster and MDX up to 2x faster than the legacy pipeline, with 25–50% lower memory use — which matters once a site crosses a few hundred pages.

When Content Collections are worth it

  • You have or expect more than ~30 Markdown / MDX files.
  • You want a failed build (not a broken page) when frontmatter is wrong.
  • Multiple templates read the same content: homepage, hub pages, sitemap, RSS feed.
  • You’ll maintain the site for years, not weeks.

If you’re starting an Astro content site in 2026, wire this up on day one. Retrofitting a 200-post folder later is far more painful than learning the schema now.

Step by step

1. Drop a few MDX files with frontmatter

Create src/content/blog/ and add a post:

---
title: "Hello world"
description: "A test post to verify content collections work end to end."
publishedAt: 2026-05-22
tags: ["intro", "test"]
author: "alice"
---

This is the body.

The glob loader reads .md, .mdx, .markdoc, .json, .yaml, and .toml, so you can mix data and prose under one base path.

2. Define the schema in src/content.config.ts

This file lives at the project root, next to astro.config.mjs — not inside src/content/. The schema is the contract: any file that violates it fails the build with a precise message.

import { defineCollection, reference } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
  schema: ({ image }) => z.object({
    title: z.string().min(10).max(80),
    description: z.string().min(120).max(160),
    publishedAt: z.coerce.date(),
    updatedAt: z.coerce.date().optional(),
    tags: z.array(z.string()).min(1),
    cover: image().optional(),
    author: reference('authors'),
    draft: z.boolean().default(false),
    featured: z.boolean().default(false),
  }),
});

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

export const collections = { blog, authors };

Two things worth noting: z.coerce.date() parses the YAML date string for you, and image() is still only available inside the schema-context callback (({ image }) => ...). It validates the file exists on disk and routes it through Astro’s image optimizer — storing a bare string path skips that entirely.

3. Run the dev server and let it validate everything

npm run dev
# [content] Could not parse frontmatter in blog → old-post.mdx
#   description: String must contain at most 160 character(s)

Astro checks every entry against the schema and points at the exact file and field. Fix everything red before moving on — that loud failure is the whole point.

4. Add the author data

Because the authors collection uses the file() loader, it’s a single JSON array, not one file per author:

[
  { "id": "alice", "name": "Alice Chen", "bio": "Indie dev writing about content sites.", "twitter": "alicewrites" }
]

5. Render a typed list page

getCollection returns fully typed entries, so your editor autocompletes entry.data.title and flags typos at build time:

---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';

const posts = (await getCollection('blog', ({ data }) => !data.draft))
  .sort((a, b) => +b.data.publishedAt - +a.data.publishedAt);
---
<ul>
  {posts.map((p) => (
    <li>
      <a href={`/blog/${p.id}/`}>{p.data.title}</a>
      <time datetime={p.data.publishedAt.toISOString()}>
        {p.data.publishedAt.toLocaleDateString()}
      </time>
    </li>
  ))}
</ul>

One gotcha on Astro 5/6: getCollection no longer guarantees a stable order, so always .sort() yourself if order matters.

6. Build the per-article route

The route file uses [...id] (rest param) so IDs containing slashes still resolve. Note render(entry) is now a top-level import, and the author reference is resolved with getEntry:

---
// src/pages/blog/[...id].astro
import { getCollection, getEntry, render } from 'astro:content';

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

const { post } = Astro.props;
const author = await getEntry(post.data.author);
const { Content } = await render(post);
---
<article>
  <h1>{post.data.title}</h1>
  <p>By {author?.data.name}</p>
  <Content />
</article>

7. Sanity-check the production build

npm run build
# 09:42:11 [build] 1209 page(s) built in 78.40s
# 09:42:11 [build] Complete!

If the build is clean, no malformed frontmatter slipped through — the schema enforced it for you.

Common pitfalls

  • Leaving the config at src/content/config.ts. On Astro 6 it’s ignored; collections silently come back empty. Move it to src/content.config.ts at the project root.
  • Copying type: 'content' from an old tutorial. That key no longer exists. Every collection needs a loader.
  • Reaching for entry.slug or entry.render(). Use entry.id and await render(entry).
  • Marking required fields .optional() “to make the build pass.” That just lets bad data through to production.
  • Editing the schema without restarting dev. Generated types are cached; a restart regenerates .astro/.
  • Expecting Live Content Collections to do everything. In Astro 6 the live variant is stable for request-time data, but it does not support image() or MDX rendering — keep editorial content in build-time collections.

When to skip this

Tiny sites under ~10 pages where the schema overhead never pays back. If you’re hand-writing a handful of static pages, plain .astro files are simpler.

FAQ

  • Which config file does Astro 6 use?: src/content.config.ts at the project root (next to astro.config.mjs). The old src/content/config.ts path was for Astro 4 and is ignored now.
  • Do Content Collections still work with MDX?: Yes. The glob() loader handles .md and .mdx (plus Markdoc, JSON, YAML, TOML). MDX files can render components inline. Live collections are the exception — they can’t render MDX.
  • What replaced entry.render() and entry.slug?: Import render from astro:content and call render(entry); entries are now plain objects identified by entry.id instead of entry.slug.
  • Where do I store JSON or YAML data like authors or tags?: Use the file() loader from astro/loaders pointed at a single JSON/YAML file containing an array; reference rows from other collections with reference('authors').
  • What happens when frontmatter is invalid?: The build fails with a message naming the file and field. That’s the feature — bad data never reaches production.
  • Do I have to rewrite my Astro 4 collections to upgrade?: Yes for anything still on the legacy API, since 6.0 removed it. Swap in a loader, move the config file, and replace slug/render() call sites.

External references: Astro Content Collections guide and the Astro 6.0 release notes.

Tags: #Indie dev #Astro #Content Collections #MDX #Getting started