Content Collections in Astro

WebAstroFrontend
Share on LinkedIn

A blog with thirty Markdown files is fine until someone typos datePublished and your homepage sorts NaN to the top. Astro Content Collections turn that pile of files into a schema-checked dataset — the same instinct as typed APIs, applied to content. Once you've been burned by a silent frontmatter typo in production, build-time validation stops feeling optional.

Define a collection

Astro 5's content layer uses a config module and loaders:

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

export const collections = { blog };

Bad frontmatter fails astro build. That's the feature. Use z.coerce.date() so ISO strings from YAML become Date objects. Add .optional() carefully — optional fields are how drafts ship without descriptions; required fields are how you keep the RSS feed honest.

Query and render

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

const posts = (await getCollection("blog"))
  .filter((p) => !p.data.draft)
  .sort((a, b) => b.data.datePublished.valueOf() - a.data.datePublished.valueOf());
---

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

For the post page:

---
import { getEntry, render } from "astro:content";
const entry = await getEntry("blog", Astro.params.slug!);
if (!entry) return Astro.redirect("/404");
const { Content, headings } = await render(entry);
---
<article>
  <h1>{entry.data.title}</h1>
  <Content />
</article>

headings is enough to build a table of contents without a second Markdown pass.

References between collections

Authors, categories, and changelog entries fit data collections:

import { defineCollection, reference, z } from "astro:content";

const authors = defineCollection({
  loader: glob({ pattern: "**/*.json", base: "./src/content/authors" }),
  schema: z.object({
    name: z.string(),
    twitter: z.string().optional(),
  }),
});

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    author: reference("authors"),
  }),
});

Resolve with getEntry(post.data.author) when rendering — no stringly-typed author names drifting from a bio file. If the author id is wrong, the build fails.

Drafts, RSS, and sitemaps

Filter draft in every public surface: index, tag pages, RSS, sitemap. Generating the feed from getCollection with the same filter as the homepage means they can't diverge. A common helper:

export async function publishedPosts() {
  return (await getCollection("blog"))
    .filter((p) => import.meta.env.DEV || !p.data.draft)
    .sort((a, b) => b.data.datePublished.valueOf() - a.data.datePublished.valueOf());
}

In dev, show drafts; in prod builds, hide them.

MDX and components

Use MDX when a post needs interactive diagrams or shared callout components. Keep the same Zod schema for .md and .mdx so the catalog stays uniform. Don't invent parallel frontmatter conventions per format.

Migration tips

Content Collections are how Astro sites stay honest as the archive grows — validation at build time beats hope at deploy time.

Reference relationships between collections

Link collections with Zod references for type-safe cross-collection queries:

// content/config.ts
const authors = defineCollection({
  type: "data",
  schema: z.object({
    name: z.string(),
    avatar: z.string().optional(),
    bio: z.string(),
  }),
});

const blog = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    author: reference("authors"),  // typed reference
    datePublished: z.date(),
    tags: z.array(z.string()).default([]),
  }),
});

// Usage in template
const post = await getEntry("blog", "my-post");
const author = await getEntry(post.data.author);
// author.data.name is typed string

References validated at build time — broken author links fail CI, not production.

Dynamic routes with getStaticPaths

Generate pages from collection entries:

// src/pages/blog/[...slug].astro
export async function getStaticPaths() {
  const posts = await getCollection("blog", ({ data }) => !data.draft);
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

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

Adding a new .md file automatically creates a route — no manual route registration. Removing a file removes the route.

Image optimization in collections

Use Astro's built-in image optimization with collection data:

import { Image } from "astro:assets";

// In schema
coverImage: image(),  // Astro image schema helper

// In template
import cover from "../../assets/cover.jpg";
<Image src={post.data.coverImage} alt={post.title} width={800} height={400} />

Images referenced in frontmatter are validated at build time — missing image file fails CI.

Failure modes

Production checklist

Resources


Frequently asked questions

What problem do Content Collections solve?

Loose Markdown frontmatter becomes a typed, validated catalog. You define a Zod schema once; Astro fails the build when a post is missing `title` or has a bad date — instead of discovering it at runtime or in production.

Can collections include non-Markdown data?

Yes. JSON and YAML data collections work for authors, changelog entries, or product catalogs. Mix Markdown posts with a JSON authors collection and reference between them with `reference()` in the schema.

How do I query posts in pages?

Use `getCollection('blog')` then filter/sort in the page or a utility. For a single entry, `getEntry('blog', slug)`. In Astro 4+/5 content layer APIs, prefer the current `getCollection` docs for your version — APIs evolved from the legacy content module.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →