Content Collections are the single most important Astro feature for agency work. They turn your Markdown content into a typed, validated data layer that catches errors at build time instead of production.
If you’re building templates that other people will fill with content (clients, content writers, junior devs), Content Collections are the difference between “it works on my machine” and “it works everywhere.”
What Content Collections Solve
Without Content Collections, your Astro pages read Markdown files with import.meta.glob() and hope the frontmatter is correct. Missing a required field? Runtime error in production. Wrong date format? Silent bug. Typo in a field name? The template renders with missing data and nobody notices until a client complains.
Content Collections add a schema layer using Zod. Every content file is validated against the schema at build time. If something’s wrong, the build fails with a clear error message pointing to the exact file and field.
Defining a Collection
Collections are defined in src/content.config.ts. Here’s a real example from our blog collection:
import { defineCollection, z } from "astro:content";
export const blogCollection = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
date: z.date(),
author: z.string().default("Admin"),
categories: z.array(z.string()).default(["others"]),
draft: z.boolean().optional(),
image: z.string().optional(),
}),
});
export const collections = {
blog: blogCollection,
};
That schema guarantees every blog post has a title, description, and date. It provides defaults for author and categories. And it makes image and draft optional.
Shared Schemas for Multi-Site
For agencies managing multiple sites, you don’t want to redefine schemas in every project. We extract common schemas into a shared package:
// packages/types/shared-schemas.ts
export const commonPageFields = {
title: z.string(),
description: z.string().optional(),
meta_title: z.string().optional(),
image: z.string().optional(),
draft: z.boolean().optional(),
};
export const ctaSectionSchema = z.object({
title: z.string(),
subtitle: z.string().optional(),
buttons: z.array(z.object({
label: z.string(),
link: z.string(),
style: z.string().optional(),
})).optional(),
});
Each site’s collections import and compose these shared schemas:
import { commonPageFields, ctaSectionSchema } from "@wumty/types/shared-schemas";
export const servicesCollection = defineCollection({
schema: z.object({
...commonPageFields,
hero: heroSectionSchema,
cta: ctaSectionSchema.optional(),
}),
});
Change a shared schema once, every site benefits. Add a new required field, and every site with missing content will fail to build with a clear error. This is how you scale content quality across 15+ sites.
Querying Collections
Astro provides getCollection() and getEntry() to query validated content:
---
import { getCollection } from "astro:content";
// Get all published blog posts, sorted by date
const posts = await getCollection("blog", ({ data }) => !data.draft);
const sorted = posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---
{sorted.map(post => (
<article>
<h2><a href={`/blog/${post.slug}/`}>{post.data.title}</a></h2>
<p>{post.data.description}</p>
</article>
))}
Every field access is type-safe. Your editor autocompletes post.data.title and flags typos. No more guessing what fields exist on a content entry.
Real-World Collection Patterns
Services with Configurable Sections
Agency sites often have service pages with variable sections. Some services need a FAQ, others need a gallery, others need a pricing table. Content Collections handle this with optional sections:
export const servicesCollection = defineCollection({
schema: z.object({
...commonPageFields,
hero: heroSectionSchema,
about: aboutBlockSchema.optional(),
faq: faqSectionSchema.optional(),
gallery: gallerySectionSchema.optional(),
pricing: pricingTableSchema.optional(),
}),
});
The template conditionally renders sections based on what’s present in the frontmatter. Add a faq: section to any service’s Markdown file, and the FAQ component appears. Remove it, and it disappears. No code changes needed.
Blog with Category Filtering
Blog posts use categories for client-side filtering:
---
title: "My Blog Post"
categories: ["tutorial", "astro"]
tags: ["beginner", "setup"]
---
The blog index page reads all unique categories from the collection and renders a filter UI. Categories are validated as string arrays at build time, so you never get a broken filter from a malformed frontmatter value.
Index Files for Section Config
Each collection can have an index file (-index.md) that configures the listing page itself (hero text, CTA, UI labels) separately from the individual entries:
# src/content/blog/-index.md
---
title: "Our Blog"
page_hero:
title: "Latest Articles"
subtitle: "Guides and tutorials for your next project."
cta_section:
title: "Need help with your project?"
buttons:
- label: "Contact Us"
link: "/contact/"
---
This pattern separates page-level config from content entries, keeping everything in the content layer and out of your Astro components.
Migration Tips
If you’re migrating from untyped Markdown to Content Collections:
- Start loose, tighten later. Use
z.any()orz.string().optional()initially, then add stricter types as you clean up content. - Run the build after each schema change. The error messages tell you exactly which files need updating.
- Use defaults for new fields.
z.string().default("Admin")lets you add a required field without updating every existing file.
Getting Started
The Wumty Starter Kit includes Content Collections pre-configured for common page types. The Single Site adds blog, FAQ, and services collections with shared Zod schemas.
For a deeper look at how we structure multi-site architectures with shared schemas, read how we manage 15+ sites from one codebase.
Want to work with content collections like the ones in this guide? The Single Site includes Zod-validated collections, blog, FAQ, and structured data. Start free with the Starter Kit or compare all tiers on the pricing page.