Mastering Astro Content Collections: A Complete Schema Guide
Astro’s content collections represent one of the framework’s most powerful features for content-heavy websites. By combining Zod schema validation with TypeScript inference, content collections provide type safety, validation, and an exceptional developer experience for managing structured content.
In this comprehensive guide, we’ll explore everything from basic setup to advanced patterns, helping you build robust, maintainable content structures.
Why Content Collections Matter
Before content collections, managing blog content in a static site generator meant:
- No type safety: Typos in frontmatter went unnoticed until runtime
- Manual validation: Each template had to defensively check for missing fields
- Inconsistent data: Nothing enforced schema consistency across posts
- Poor autocomplete: Editors couldn’t help you with field names
Content collections solve all of this:
// Without content collections
const post = await Astro.glob('../content/*.md');
const title = post.frontmatter.title; // any - no type info
const date = post.frontmatter.date; // might not exist!
// With content collections
import { getCollection } from 'astro:content';
const posts = await getCollection('posts');
posts[0].data.title; // string - fully typed!
posts[0].data.pubDatetime; // Date - validated!
Setting Up Content Collections
Let’s build a content collection from scratch.
Directory Structure
Content collections live in src/content/:
src/
content/
config.ts # Schema definitions
posts/ # Collection directory
hello-world.md
getting-started.mdx
authors/ # Another collection
sarah.json
marcus.json
The Config File
The config.ts file defines your schemas:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const postsCollection = defineCollection({
type: 'content', // Markdown/MDX with frontmatter
schema: z.object({
title: z.string(),
description: z.string(),
pubDatetime: z.date(),
author: z.string().default('Anonymous'),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
const authorsCollection = defineCollection({
type: 'data', // JSON/YAML data files
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string().url(),
social: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}),
}),
});
export const collections = {
posts: postsCollection,
authors: authorsCollection,
};
Zod Schema Patterns
Zod is a TypeScript-first schema declaration library. Let’s explore patterns for content validation.
Required vs Optional Fields
const schema = z.object({
// Required - must be present
title: z.string(),
// Optional - can be undefined
subtitle: z.string().optional(),
// Default value - uses default if missing
draft: z.boolean().default(false),
// Nullable - can be explicitly null
featuredImage: z.string().nullable(),
// Optional with null - can be undefined OR null
customOgImage: z.string().optional().nullable(),
});
String Validation
const schema = z.object({
// Basic string
title: z.string(),
// Length constraints
slug: z.string().min(1).max(100),
// Pattern matching
slug: z.string().regex(/^[a-z0-9-]+$/),
// URL validation
website: z.string().url(),
// Email validation
contact: z.string().email(),
// Enum/literal unions
status: z.enum(['draft', 'review', 'published']),
// Transform on parse
normalizedTitle: z.string().transform(s => s.toLowerCase()),
});
Date Handling
const schema = z.object({
// Date object (Astro parses YAML dates automatically)
pubDatetime: z.date(),
// String that should be a date
dateString: z.string().datetime(),
// Coerce string to date
publishedAt: z.coerce.date(),
// Optional with null for "never modified"
modDatetime: z.date().optional().nullable(),
});
Arrays and Objects
const schema = z.object({
// Array of strings
tags: z.array(z.string()),
// Array with min/max length
categories: z.array(z.string()).min(1).max(5),
// Default empty array
keywords: z.array(z.string()).default([]),
// Nested object
author: z.object({
name: z.string(),
email: z.string().email(),
}),
// Array of objects
contributors: z.array(
z.object({
name: z.string(),
role: z.enum(['writer', 'editor', 'reviewer']),
})
).default([]),
});
Image Handling with Context
Astro provides a special image() helper for validated image references:
import { defineCollection, z } from 'astro:content';
const postsCollection = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
// Validates the image exists and returns metadata
cover: image(),
// Optional image
ogImage: image().optional(),
// Image or external URL string
hero: image().or(z.string().url()),
// Image with refinement
thumbnail: image().refine(
(img) => img.width >= 400,
'Thumbnail must be at least 400px wide'
),
}),
});
AstroPaper’s Schema Implementation
Let’s examine how AstroPaper implements its content schema:
// AstroPaper's blog schema
const blogSchema = ({ image }: SchemaContext) =>
z.object({
// Required fields - must be in every post
title: z.string(),
pubDatetime: z.date(),
description: z.string(),
// Optional with defaults
author: z.string().default(SITE.author),
featured: z.boolean().optional(),
draft: z.boolean().optional(),
tags: z.array(z.string()).default(['others']),
// Flexible image handling
ogImage: image()
.or(z.string().url())
.optional(),
// Optional metadata
modDatetime: z.date().optional().nullable(),
canonicalURL: z.string().url().optional(),
hideEditPost: z.boolean().optional(),
timezone: z.string().optional(),
});
Why Certain Fields Are Required
| Field | Required? | Reason |
|---|---|---|
| title | Yes | Core identification |
| pubDatetime | Yes | Sorting, RSS, SEO |
| description | Yes | SEO, previews, OG images |
| author | No (default) | Can fall back to site default |
| tags | No (default) | Defaults to [‘others’] |
Advanced Schema Patterns
Discriminated Unions
Handle different content types with a single collection:
const contentSchema = z.discriminatedUnion('type', [
// Blog post type
z.object({
type: z.literal('post'),
title: z.string(),
pubDatetime: z.date(),
content: z.string(),
}),
// Tutorial type
z.object({
type: z.literal('tutorial'),
title: z.string(),
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
estimatedTime: z.number(), // minutes
prerequisites: z.array(z.string()),
}),
// Tool/resource type
z.object({
type: z.literal('tool'),
name: z.string(),
category: z.string(),
url: z.string().url(),
pricing: z.enum(['free', 'freemium', 'paid']),
}),
]);
Cross-Collection References
Reference content from other collections:
import { reference, defineCollection, z } from 'astro:content';
const posts = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
// Reference to an author in the authors collection
author: reference('authors'),
// Array of references
relatedPosts: z.array(reference('posts')).default([]),
}),
});
const authors = defineCollection({
type: 'data',
schema: z.object({
name: z.string(),
bio: z.string(),
}),
});
Using references in components:
---
import { getCollection, getEntry } from 'astro:content';
const posts = await getCollection('posts');
for (const post of posts) {
// Resolve the reference
const author = await getEntry(post.data.author);
console.log(`${post.data.title} by ${author.data.name}`);
}
---
Custom Validation with Refinements
const postSchema = z.object({
title: z.string(),
pubDatetime: z.date(),
modDatetime: z.date().optional(),
}).refine(
(data) => {
// Mod date must be after pub date
if (data.modDatetime && data.pubDatetime) {
return data.modDatetime > data.pubDatetime;
}
return true;
},
{
message: 'modDatetime must be after pubDatetime',
path: ['modDatetime'],
}
);
Transform and Preprocess
const schema = z.object({
// Transform slug from title if not provided
title: z.string(),
slug: z.string().optional().transform((val, ctx) => {
if (val) return val;
// Access other fields via ctx - not directly possible,
// so we handle this at collection level
return undefined;
}),
// Normalize tags to lowercase
tags: z.array(z.string())
.transform(tags => tags.map(t => t.toLowerCase())),
// Parse comma-separated string to array
keywords: z.string()
.transform(str => str.split(',').map(s => s.trim()))
.optional(),
});
Error Handling and Debugging
Build-Time Validation Errors
When content doesn’t match the schema, Astro provides helpful errors:
[ERROR] Invalid content in src/content/posts/my-post.md:
- Required field 'description' is missing
- 'pubDatetime' must be a Date (received: string)
- 'tags[2]' must be a string (received: number)
Custom Error Messages
const schema = z.object({
title: z.string({
required_error: 'Every post needs a title',
invalid_type_error: 'Title must be a string',
}),
pubDatetime: z.date({
required_error: 'Publication date is required for sorting',
}),
description: z.string().min(50, {
message: 'Description should be at least 50 characters for SEO',
}),
});
Debugging Schema Issues
Create a validation helper for development:
// scripts/validate-content.ts
import { getCollection } from 'astro:content';
async function validateContent() {
try {
const posts = await getCollection('posts');
console.log(`✓ ${posts.length} posts validated successfully`);
// Check for potential issues
for (const post of posts) {
if (post.data.description.length < 100) {
console.warn(`⚠ Short description: ${post.id}`);
}
if (post.data.tags.length === 0) {
console.warn(`⚠ No tags: ${post.id}`);
}
}
} catch (error) {
console.error('Validation failed:', error);
process.exit(1);
}
}
validateContent();
Type Inference and TypeScript Integration
One of content collections’ superpowers is TypeScript integration:
import type { CollectionEntry } from 'astro:content';
// Full type inference from your schema
type Post = CollectionEntry<'posts'>;
// The data property is fully typed
function formatPostPreview(post: Post) {
return {
title: post.data.title, // string
date: post.data.pubDatetime, // Date
tags: post.data.tags, // string[]
// TypeScript error: Property 'nonexistent' does not exist
// bad: post.data.nonexistent,
};
}
// Extract just the data type
type PostData = Post['data'];
// Use in component props
interface Props {
post: Post;
}
Best Practices
1. Start Strict, Loosen Later
Begin with required fields and add defaults/optionals as needed:
// Start strict
z.object({
title: z.string(),
description: z.string(),
pubDatetime: z.date(),
})
// Add flexibility as patterns emerge
z.object({
title: z.string(),
description: z.string().optional(), // Made optional
pubDatetime: z.coerce.date(), // More flexible parsing
})
2. Use Meaningful Defaults
z.object({
// Good: Meaningful default
author: z.string().default('Editorial Team'),
tags: z.array(z.string()).default(['uncategorized']),
// Avoid: Defaults that hide problems
title: z.string().default('Untitled'), // Too permissive
})
3. Document Your Schema
/**
* Blog post schema
*
* Required fields:
* - title: Post title (displayed in listings and SEO)
* - pubDatetime: Publication date (used for sorting and RSS)
* - description: SEO description (min 100 chars recommended)
*
* Optional fields:
* - modDatetime: Last modification (shown if different from pub)
* - tags: Categorization (defaults to ['others'])
*/
const blogSchema = z.object({ /* ... */ });
4. Consider Migration Paths
Plan for schema evolution:
// Version your schema changes
const schemaV2 = z.object({
// New required field
excerpt: z.string(),
// Renamed field with backward compat
pubDatetime: z.date().or(z.date().optional().transform(d => d ?? new Date())),
});
Conclusion
Astro’s content collections with Zod validation provide:
- Type safety: Full TypeScript inference from your schema
- Build-time validation: Catch errors before deployment
- Developer experience: Autocomplete, documentation, and clear errors
- Flexibility: Handle any content structure you need
By investing time in your schema design, you create a foundation for maintainable, reliable content management. The upfront work pays dividends in reduced bugs, faster development, and confident refactoring.
Related Reading: