A template is only as good as the worst frontmatter anyone will ever paste into it. When the person filling a site is a client, a copywriter or a junior developer, “it worked on my machine” is not a defense. Content Collections are Astro’s answer: your Markdown becomes a typed, validated data layer, and mistakes fail the build instead of shipping.
This guide covers the parts that matter for template work: defining a collection, sharing schemas across many sites, modelling optional sections, and configuring listing pages from the content layer.
The Problem They Solve
Without a schema, an Astro page reads Markdown with import.meta.glob() and trusts that the frontmatter is right. A missing required field becomes a runtime error. A date in the wrong format parses as garbage and sorts posts into nonsense. A typo in a field name renders a page with a blank hero, and nobody notices until the client does.
With a schema, every file is checked against Zod at build time. If something is off, the build stops with a message that names the file and the field.
Defining a Collection
Collections live in src/content.config.ts. Each one needs a loader that says where the entries come from, and (optionally, but you always want it) a schema:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.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 };
Two details trip people up. The glob() loader gives every entry an id derived from its filename, and that id (not slug) is what you use to build URLs. And z.coerce.date() is the safe way to accept a frontmatter date, because YAML may hand you a string or a Date object depending on how the value was written.
Shared Schemas Across Many Sites
An agency running several sites should not redefine “what a page looks like” in each one. Put the common pieces in a shared package and compose them:
// packages/types/shared-schemas.ts
import { z } from "astro/zod";
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 content.config.ts imports and extends them:
import { commonPageFields, ctaSectionSchema } from "@amrify/types/shared-schemas";
const services = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/services" }),
schema: z.object({
...commonPageFields,
hero: heroSectionSchema,
cta: ctaSectionSchema.optional(),
}),
});
Now a schema change is one edit. Add a required field and every site that lacks it refuses to build, which sounds harsh until you remember the alternative is every site quietly rendering an empty section. This is the mechanism that keeps content quality level across a fleet.
Querying
getCollection() returns validated entries, and every field on entry.data is typed:
---
import { getCollection } from "astro:content";
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.id}/`}>{post.data.title}</a></h2>
<p>{post.data.description}</p>
</article>
))}
Your editor autocompletes post.data.title and underlines post.data.titel. No more opening three Markdown files to remember what a field was called.
Patterns That Earn Their Keep
Optional Sections on Service Pages
Service pages rarely share one shape. Roof repair wants an FAQ, gutter installation wants a gallery, and the emergency page wants neither. Model that with optional sub-schemas:
const services = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/services" }),
schema: z.object({
...commonPageFields,
hero: heroSectionSchema,
about: aboutBlockSchema.optional(),
faq: faqSectionSchema.optional(),
gallery: gallerySectionSchema.optional(),
pricing: pricingTableSchema.optional(),
}),
});
The template renders whatever is present. Add a faq: block to a service’s frontmatter and the FAQ appears (with its FAQPage JSON-LD, if your layout emits it). Delete the block and the section is gone. Nobody touched a component.
Categories You Can Trust
If your blog index builds a filter from categories, validate them as a string array with a default. A malformed value then fails the build instead of producing a filter button labelled undefined:
---
title: "My Blog Post"
categories: ["tutorial", "astro"]
tags: ["beginner", "setup"]
---
Index Files for the Listing Page
The listing page has its own copy: a hero, a call to action, UI labels. Keep it in the content layer too, in a file the loader can find but your post query can skip. We use a leading dash (-index.md) and filter it out of listing queries by id:
# 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/"
---
Page-level config next to the entries it governs, and nothing hard-coded in an .astro file.
Tightening an Untyped Project
Moving an existing site onto schemas goes smoother in this order:
- Start loose.
z.any()orz.string().optional()on the fields you have not audited yet. Get the build green first. - Tighten one field at a time and rebuild. Each failure names the exact files to fix.
- Use defaults for new required fields.
z.string().default("Admin")lets you introduce a field without editing every existing entry the same afternoon. - Clear
.astro/after a schema or plugin change. Astro caches the content layer; a stale cache can serve old shapes and make you chase a bug that no longer exists.
Where This Sits in Amrify
The free Amrify Starter (public repo opens at launch) ships with these collections already defined for services, posts, FAQs and page copy, on a single validated site.json. Amrify Pro reuses the same Zod schemas and adds the ten trade templates plus the service-area collections, which come with a build gate that refuses thin city pages rather than letting them index.
For the multi-site side of this, read how one codebase runs a whole fleet of live sites.
Want to see what these schemas render to before you write one? Open the instant demo, type a business name and trade, and you are looking at a trade template built from collections exactly like the ones above. When you are ready to build on them, the Starter is free at launch and Pro is $249 one-time for the first 5,000 members.