Content Model Guide - Schema Design Patterns
Overview
Section titled “Overview”This guide covers the content schema patterns used in this project’s Astro Content Collections. All schemas live in src/content.config.ts and use Zod for build-time validation with full TypeScript inference.
Core Principles
Section titled “Core Principles”- Type Safety First: Zod schemas provide runtime validation and TypeScript inference
- Future-Proof: Optional fields and defaults allow content to evolve without breaking
- DRY: Common patterns (tags, dates, draft/featured flags) repeat across collections
- Validation: Malformed content fails at build time, not in production
- Flexibility: Mix Markdown, MDX, and JSON sources across collections
Collection Setup
Section titled “Collection Setup”Every collection uses glob loaders to source files from src/content/<name>/:
import { defineCollection } from "astro:content";import { glob } from "astro/loaders";import { z } from "astro/zod";
const blogCollection = defineCollection({ loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/blog" }), schema: ({ image }) => z.object({ // ... fields }),});
export const collections = { blog: blogCollection, // all collections must be exported here};The [^_]* glob pattern excludes files prefixed with _, useful for drafts or partials you want to keep in the directory but exclude from the collection.
Schema Patterns
Section titled “Schema Patterns”Image Schema Pattern
Section titled “Image Schema Pattern”Astro’s image() helper validates image paths at build time and enables automatic optimization (AVIF/WebP). Access it via the schema function signature:
const projectsCollection = defineCollection({ loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/projects" }), schema: ({ image }) => z.object({ cover: image(), // Required — build fails if missing coverAlt: z.string(), // Always pair with alt text cardImage: image().optional(), // Optional variant for card thumbnails }),});Use image() (not z.string()) for any frontmatter image field. This enables Sharp processing and ensures referenced files exist at build time.
The bio collection uses the same pattern for avatars:
schema: ({ image }) => z.object({ avatar: image(), // Processed through Astro Image pipeline }),Optional vs Required Fields
Section titled “Optional vs Required Fields”Use .optional() for fields that not every entry needs. Use .default() for fields that should always have a value:
// Projects collection — mixing required, optional, and defaulted fieldsz.object({ title: z.string(), // Required — every project needs a title description: z.string().max(160), // Required + constrained for SEO date: z.date(), // Required draft: z.boolean().default(false), // Defaulted — published unless marked draft featured: z.boolean().default(false), // Defaulted — not featured unless marked client: z.string().optional(), // Optional — not all projects have a client duration: z.string().optional(), // Optional role: z.string().optional(), // Optional externalUrl: z.url().optional(), // Optional — validated as URL when present githubUrl: z.url().optional(), // Optional — source repo, paired with externalUrl sortOrder: z.number().default(0), // Defaulted — manual ordering override})The blog collection demonstrates .default() with non-boolean types:
z.object({ tags: z.array(z.string()).default([]), // Defaults to empty array technologies: z.array(z.string()).default([]), // Same pattern author: z.string().default("Your Name"), // Default string value})Rule of thumb: Use .default() when templates should always have a value to render. Use .optional() when templates conditionally render based on presence.
Array of Objects Pattern
Section titled “Array of Objects Pattern”For structured repeated data, use z.array(z.object({...})). This validates each item in the array:
// Projects — measurable outcomes with structured metricsoutcomes: z .array( z.object({ metric: z.string(), // e.g., "Page Load Time" value: z.string(), // e.g., "1.2s" — string to allow units description: z.string().optional(), }), ) .optional(),// Bio — skills grouped by categoryskills: z .array( z.object({ category: z.string(), // e.g., "Frontend", "DevOps" items: z.array(z.string()), // e.g., ["TypeScript", "React", "Astro"] }), ) .optional(),// Experience — highlight bulletshighlights: z.array(z.string()).optional(), // Simple string arraytechnologies: z.array(z.string()).optional(),Use string arrays for flat lists (tags, technologies, highlights). Use object arrays when each item has multiple properties (outcomes, skills).
Date Handling Pattern
Section titled “Date Handling Pattern”Zod’s z.date() parses YAML date values from frontmatter. Use .optional() for open-ended ranges:
// Experience — date ranges with optional end for current positionsz.object({ startDate: z.date(), endDate: z.date().optional(), // Omit for current role current: z.boolean().default(false), // Explicit flag for "present"})
// Blog — publish date with optional update trackingz.object({ date: z.date(), // Original publish date updated: z.date().optional(), // Set when content is revised})In frontmatter, dates use YAML date format:
date: 2025-06-10startDate: 2023-01-15endDate: 2024-03-01Nested Object Pattern
Section titled “Nested Object Pattern”For structured metadata that isn’t repeated, use inline z.object():
// Bio — social links as a typed objectsocial: z .object({ github: z.url().optional(), linkedin: z.url().optional(), twitter: z.url().optional(), email: z.email().optional(), }) .optional(),Note z.url() and z.email() for format validation — these catch malformed URLs/emails at build time rather than producing broken links in production.
JSON Collections Pattern
Section titled “JSON Collections Pattern”Not all content is Markdown. The navigation collection uses JSON files with a different glob pattern:
const navigationCollection = defineCollection({ loader: glob({ pattern: "**/*.{json,yaml,yml}", base: "./src/content/navigation" }), schema: z.object({ items: z.array( z.object({ label: z.string(), href: z.string(), isExternal: z.boolean().default(false), icon: z.string().optional(), order: z.number().default(0), }), ), }),});JSON collections don’t use the ({ image }) function form since they can’t reference local images. The schema is passed directly as an object.
Example src/content/navigation/header.json (the shipped file — trimmed):
{ "items": [ { "label": "Home", "href": "/", "order": 1 }, { "label": "How It Works", "href": "/how-it-works/", "order": 2 }, { "label": "Blog", "href": "/blog/", "order": 4 }, { "label": "GitHub", "href": "https://github.com/clownware/astro-performance-starter", "isExternal": true, "icon": "github-logo", "order": 8 } ]}Cross-Reference Pattern
Section titled “Cross-Reference Pattern”Two patterns ship side by side.
Typed references (projects.relatedPosts). reference() from astro:content turns each frontmatter id into a { collection, id } reference that getEntries() resolves:
// Projects — write-ups about the project, by blog idrelatedPosts: z.array(reference("blog")).optional(),relatedPosts: ["building-this-template", "design-decisions"]Astro logs an unknown id at build time but does not fail the build on it. getRelatedPosts() in src/utils/blog.ts resolves the references, throws on a missing post (which does fail the build) and drops drafts. The project page renders the result as “Related Writing”.
Plain id strings (blog.relatedPosts). The blog schema keeps the original string form recorded in ADR-027:
// Blog — related posts by slugrelatedPosts: z.array(z.string()).optional(), // IDs of related blog entriesNothing checks these ids; validate them in your templates if you use them.
Querying and Filtering
Section titled “Querying and Filtering”Basic Collection Query
Section titled “Basic Collection Query”import { getCollection } from "astro:content";
const navigation = await getCollection("navigation");const navItems = navigation[0]?.data.items || [];Filtering with Inline Predicate
Section titled “Filtering with Inline Predicate”Pass a filter function as the second argument to getCollection to exclude entries at query time:
import { type CollectionEntry, getCollection } from "astro:content";
// Filter out draftsconst posts = await getCollection( "blog", ({ data }: CollectionEntry<"blog">) => data.draft !== true,);
// Filter out drafts from projectsconst projects = await getCollection( "projects", ({ data }: CollectionEntry<"projects">) => !data.draft,);Compound Filters
Section titled “Compound Filters”Combine conditions in the predicate for more specific queries:
// Featured, non-draft postsconst featuredPosts = await getCollection( "blog", ({ data }: CollectionEntry<"blog">) => data.draft !== true && data.featured === true,);Sorting and Limiting
Section titled “Sorting and Limiting”Sort and slice after querying — getCollection returns a plain array:
// Sort by date descendingfunction sortPostsByDate( posts: CollectionEntry<"blog">[],): CollectionEntry<"blog">[] { return posts.sort( (a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime(), );}
// Usage: get 3 newest featured postsconst featured = sortPostsByDate(featuredPosts).slice(0, 3);Centralized Query Utilities
Section titled “Centralized Query Utilities”Extract repeated query patterns into utility functions (see src/utils/blog.ts):
export async function getPublishedPosts(): Promise<CollectionEntry<"blog">[]> { const allPosts = await getCollection( "blog", ({ data }: CollectionEntry<"blog">) => data.draft !== true, ); return sortPostsByDate(allPosts);}
export async function getFeaturedPosts( limit = 3,): Promise<CollectionEntry<"blog">[]> { const featuredPosts = await getCollection( "blog", ({ data }: CollectionEntry<"blog">) => data.draft !== true && data.featured === true, ); return sortPostsByDate(featuredPosts).slice(0, limit);}Static Path Generation
Section titled “Static Path Generation”Use getStaticPaths with collection queries for dynamic routes:
export async function getStaticPaths() { const posts = await getCollection("blog"); return posts.map((post) => ({ params: { slug: post.id }, props: { post }, }));}Collection Reference
Section titled “Collection Reference”| Collection | Source Format | Loader Pattern | Image Support |
|---|---|---|---|
projects | MD/MDX | **/[^_]*.{md,mdx} | Yes ({ image }) |
blog | MD/MDX | **/[^_]*.{md,mdx} | Yes ({ image }) |
bio | MD/MDX/JSON | **/[^_]*.{md,mdx,json} | Yes ({ image }) |
experience | MD/MDX | **/[^_]*.{md,mdx} | No |
navigation | JSON/YAML | **/*.{json,yaml,yml} | No |