Astro Content Collections: Real-World schemas you can steal
A production-ready Astro content config used in real client projects: blog posts, services, projects, properties, agents, legal pages, testimonials, FAQs, and recursive navigation.
Published on December 3, 2025 by Michael AndreuzzaIf you are still shipping Astro sites with bare-minimum content schemas, you are leaving a lot of safety (and automation) on the table. Real client work needs strongly typed media objects, references, enums, and metadata structures that match how the marketing team actually maintains the site.
Over the past year I standardized on the following Astro Content Collections file for agency projects that mix SaaS marketing, services, portfolio work, and a real-estate directory. It keeps authors honest, gives CMS-level guardrails, and works beautifully with astro:content tooling.
What you get
In this post you’ll get:
- A full
config.tswith 10 production-ready collections. - Blog, services, projects, properties, agents, legal pages, testimonials, FAQs, and navigation.
- Cross-collection references for properties ⇄ agents.
- Recursive navigation that can power mega menus and complex footers.
- Opinionated SEO, media, and metadata fields you can copy as-is or trim down.
We’ll walk through the collections first so you understand the shape and the tradeoffs, and at the end you’ll get the full file you can drop straight into /src/content/config.ts.
Collection breakdown
Blog posts
Every article stores hero media, author attribution, optional structured data for SEO, and boolean flags for drafts or homepage features. That means you can power Open Graph cards, reading time badges, and editorial workflows from a single source.
I keep an explicit slug field so URLs are not permanently tied to the folder/file structure. If marketing wants to refactor URL patterns later, you can migrate slugs without renaming half the repo.
Highlights:
heromedia object with optional focal point, caption, and credit.seoobject for per-post meta, Open Graph, and structured data.draftandfeaturedbooleans for editorial workflows.readingTimeas a number so you can render badges without re-calculating.
Services
Services behave like CMS entries used across pricing and landing pages. Ordered integers keep sections stable, and the optional CTA object wires up hero buttons without hardcoding URLs in layouts.
Because this is a data collection, you can drive it from JSON/YAML files if that’s easier for non-technical collaborators.
Highlights:
tagline+longDescriptionso you can use the same entry in cards and detail pages.categoryenum to segment services (strategy, design, dev, etc.).orderas an integer to avoid “drag-and-drop” reordering inside templates.- Optional
ctablock for deep-link buttons.
Projects
The gallery array enforces alt text and dimensions (handy when you need responsive images). Metrics and link objects let you surface tangible results or case-study CTAs in cards.
Projects are content entries so they can hold longer narrative copy, code samples, or rich MDX blocks.
Highlights:
galleryas an array of media objects for case-study layouts.metricsas reusable “results” chips (+38% conversions, etc.).linksblock for live site and repo URLs.statusenum (planned,in-progress,launched,archived) for timeline views.
Properties + Agents
Properties and agents are content collections tied together by reference():
- Each property points to an
agent. - Each agent can optionally list
propertiesback.
Cross-collection references keep listings in sync with agent bios, so you never duplicate contact data. Latitude/longitude pairs open the door to map overlays, neighborhood pages, or location-aware SEO snippets.
Highlights (properties):
statusenum (available,sold,rented).- Structured
addressobject for localized formatting. sizewith value + unit (sqft/sqm).gallery+ optionalfloorPlansarrays.agent: reference("agents")for cross-linking.
Highlights (agents):
socialLinksarray with a platform enum.- Optional
licenseIdfor markets that need it. - Optional
properties: reference("properties")[]for reverse listing pages.
Legal pages
Marketing or legal teams can update policy content while keeping version numbers and noindex flags aligned with compliance. These live in content so you can use Markdown for formatting and headings.
Highlights:
lastUpdateddate for compliance.- Optional
versionstring for internal tracking. seoblock withnoindexflag so policies don’t pollute search.
Testimonials & FAQs
Ratings, company, and category enums keep things sortable on landing pages, while weight lets you surface “priority” FAQs first.
Testimonials stay in data so you can reuse the same quotes across multiple themes or sections without dragging in Markdown formatting.
Highlights (testimonials):
rating1–5 for star components.featuredboolean to pin specific quotes.avatarimage object for more trustworthy layouts.
Highlights (FAQs):
categoryenum (general,billing,technical, etc.) for tabbed layouts.- Optional
weightfield so you can order FAQs without hacks.
Navigation
Recursive navigationItemSchema lets you model real megamenus and deep footers without rewriting shape definitions in multiple places.
The navigation collection itself is a simple data object with an items array:
- Top-level items.
- Nested
childrenfor dropdowns and secondary links. - Optional
rel,external, andariaLabelfields for accessibility and SEO.
How to use it
- Drop the full config (below) into
/src/content/config.ts. - Create folders for
posts,services,projects,properties,agents,legal,testimonials,faqs, andnavigationinsidesrc/content. - Start populating entries; the Zod schemas will throw helpful validation errors whenever someone tries to skip required media, forget tags, or mislabel a category.
This setup gives you CMS-grade safety without leaving the repo. Once a team adopts it, their designers and marketers can finally trust that what they ship in Markdown and data files actually matches the site’s requirements.
Full production config (copy-paste)
import { defineCollection, reference, z } from "astro:content";
const serviceCategories = z.enum([
"strategy",
"design",
"development",
"marketing",
"maintenance",
]);
const projectStatus = z.enum([
"planned",
"in-progress",
"launched",
"archived",
]);
const projectCategories = z.enum([
"brand",
"web",
"mobile",
"commerce",
"infrastructure",
]);
const propertyStatus = z.enum(["available", "sold", "rented"]);
const faqCategories = z.enum([
"general",
"billing",
"technical",
"legal",
"sales",
]);
const navigationItemSchema = z.lazy(() =>
z.object({
label: z.string(),
url: z.string(),
ariaLabel: z.string().optional(),
rel: z.string().optional(),
external: z.boolean().optional(),
children: z.array(navigationItemSchema).optional(),
})
);
const posts = defineCollection({
type: "content",
schema: ({ image }) => {
const media = z.object({
src: image(),
alt: z.string(),
focalPoint: z
.object({
x: z.number().min(0).max(1),
y: z.number().min(0).max(1),
})
.optional(),
caption: z.string().optional(),
credit: z.string().optional(),
});
const seo = z.object({
title: z.string().optional(),
description: z.string(),
canonicalUrl: z.string().url().optional(),
keywords: z.array(z.string()).min(1).optional(),
noindex: z.boolean().default(false),
ogImage: media.optional(),
twitterImage: media.optional(),
structuredData: z.record(z.unknown()).optional(),
});
return z.object({
title: z.string(),
slug: z.string(),
description: z.string(),
pubDate: z.date(),
updatedDate: z.date().optional(),
hero: media,
tags: z.array(z.string()).min(1),
readingTime: z.number().positive(),
author: z.object({
name: z.string(),
role: z.string().optional(),
avatar: media.optional(),
url: z.string().url().optional(),
}),
seo,
draft: z.boolean().default(false),
featured: z.boolean().default(false),
});
},
});
const services = defineCollection({
type: "data",
schema: ({ image }) => {
const serviceImage = z.object({
src: image(),
alt: z.string(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
});
return z.object({
title: z.string(),
slug: z.string(),
tagline: z.string(),
longDescription: z.string(),
image: serviceImage,
category: serviceCategories,
order: z.number().int().nonnegative(),
published: z.boolean().default(true),
featured: z.boolean().default(false),
cta: z
.object({
label: z.string(),
url: z.string(),
})
.optional(),
});
},
});
const projects = defineCollection({
type: "content",
schema: ({ image }) => {
const galleryItem = z.object({
src: image(),
alt: z.string(),
caption: z.string().optional(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
});
return z.object({
title: z.string(),
client: z.string(),
year: z.number().int(),
description: z.string(),
summary: z.string().optional(),
gallery: z.array(galleryItem).min(1),
categories: z.array(projectCategories).min(1),
featured: z.boolean().default(false),
status: projectStatus,
metrics: z
.array(
z.object({
label: z.string(),
value: z.string(),
})
)
.optional(),
links: z
.object({
live: z.string().url().optional(),
repo: z.string().url().optional(),
})
.optional(),
});
},
});
const properties = defineCollection({
type: "content",
schema: ({ image }) => {
const propertyImage = z.object({
src: image(),
alt: z.string(),
caption: z.string().optional(),
});
return z.object({
title: z.string(),
slug: z.string(),
price: z.number().positive(),
status: propertyStatus,
address: z.object({
street: z.string(),
suite: z.string().optional(),
city: z.string(),
state: z.string(),
postalCode: z.string(),
country: z.string(),
}),
bedrooms: z.number().nonnegative(),
bathrooms: z.number().nonnegative(),
size: z.object({
value: z.number().positive(),
unit: z.enum(["sqft", "sqm"]).default("sqft"),
}),
location: z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
}),
gallery: z.array(propertyImage).min(1),
floorPlans: z.array(propertyImage).optional(),
agent: reference("agents"),
tags: z.array(z.string()).min(1),
amenities: z.array(z.string()).optional(),
featured: z.boolean().default(false),
published: z.boolean().default(true),
});
},
});
const agents = defineCollection({
type: "content",
schema: ({ image }) => {
const avatar = z.object({
src: image(),
alt: z.string(),
});
return z.object({
name: z.string(),
slug: z.string(),
role: z.string(),
avatar,
phone: z.string(),
email: z.string().email(),
bio: z.string(),
licenseId: z.string().optional(),
socialLinks: z
.array(
z.object({
platform: z.enum([
"instagram",
"facebook",
"linkedin",
"twitter",
"youtube",
"tiktok",
"website",
]),
url: z.string(),
handle: z.string().optional(),
})
)
.min(1),
properties: z.array(reference("properties")).optional(),
});
},
});
const legalPages = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
slug: z.string(),
content: z.string(),
lastUpdated: z.date(),
version: z.string().optional(),
seo: z.object({
title: z.string(),
description: z.string(),
canonicalUrl: z.string().url().optional(),
noindex: z.boolean().default(false),
}),
}),
});
const testimonials = defineCollection({
type: "data",
schema: ({ image }) => {
const avatar = z.object({
src: image(),
alt: z.string(),
});
return z.object({
name: z.string(),
role: z.string(),
company: z.string(),
quote: z.string(),
avatar,
rating: z.number().min(1).max(5),
location: z.string().optional(),
featured: z.boolean().default(false),
});
},
});
const faqs = defineCollection({
type: "data",
schema: z.object({
question: z.string(),
answer: z.string(),
category: faqCategories,
weight: z.number().int().optional(),
}),
});
const navigation = defineCollection({
type: "data",
schema: z.object({
items: z.array(navigationItemSchema),
}),
});
export const collections = {
posts,
services,
projects,
properties,
agents,
legal: legalPages,
testimonials,
faqs,
navigation,
};
Astro’s image() helper only accepts files inside your src/images directory. If you reference anything from public/ or an external URL, it will throw a build error.
Ready to build?
This config gives you a strong starting point for almost any marketing site: services, projects, real estate, navigation, legal pages, and more. It’s the structure I rely on when shipping work for agencies and product teams who can’t afford messy content or inconsistent metadata.
If you want an even stronger head start, all Lexington themes are built on top of these patterns—battle-tested collections, clean components, and layouts that scale. Grab one, drop this schema in, and you’re basically ready for production.
Either way: use this, steal from it, and ship something great.
/Michael Andreuzza