What You Are Learning
This tutorial explains how to use Astro Content Collections and schemas.
Content Collections let you organize Markdown, MDX, and other content into named groups. Schemas let you define what frontmatter fields each content type is expected to have.
Markdown/MDX files
↓
Content Collection
↓
Schema validation
↓
Safe, organized content data
↓
Pages, indexes, cards, filters, and related links
Why Content Collections Matter
Without schemas, each page can accidentally use different frontmatter.
---
title: "Veritas Miraculo"
faction: "House Miraculo"
---
---
name: "Aurelian Rassendyll"
house: "Valoire"
---
Both examples describe characters, but they use different field names. That becomes hard to search, sort, display, or reuse.
With a schema, you can require consistent fields:
title
description
type
region
faction
status
tags
This helps Astro catch mistakes before the site is built.
Basic Terms
| Term | Meaning |
|---|---|
| Collection | A named group of content files, such as lessons, characters, regions, or posts. |
| Entry | One content file inside a collection. |
| Frontmatter | The YAML metadata block at the top of a Markdown or MDX file. |
| Schema | A rule set that defines which frontmatter fields are allowed or required. |
| Zod | The validation library Astro uses for schemas. |
Project Folder Structure
A common Astro content structure looks like this:
src/
content/
lessons/
markdown-basics.md
mdx-basics.md
characters/
veritas-miraculo.md
aurelian-rassendyll.md
regions/
europe.md
austral-confluence.md
content.config.ts
Each folder inside src/content/ can become a collection.
src/content/lessons/ → lessons collection
src/content/characters/ → characters collection
src/content/regions/ → regions collection
The configuration file is usually:
src/content.config.ts
Important Note for Starlight
Starlight documentation pages usually live in:
src/content/docs/
That docs collection is special to Starlight. You can still create other collections beside it:
src/content/
docs/
index.md
guides/
getting-started.md
characters/
veritas-miraculo.md
lessons/
markdown-basics.md
regions/
europe.md
A practical Starlight site might use docs for normal documentation pages and separate collections for structured data like characters, regions, lessons, or factions.
Create a Basic Content Config File
Create this file:
src/content.config.ts
Add a simple collection:
import { defineCollection, z } from 'astro:content';
const lessons = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
level: z.enum(['beginner', 'intermediate', 'advanced']),
order: z.number(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = {
lessons,
};
This says that every lesson entry should have a title, description, level, order number, and tags list.
Create a Lesson Entry
Create this file:
src/content/lessons/markdown-basics.md
Add this content:
---
title: "Markdown Basics"
description: "Learn how to write headings, lists, links, and code blocks."
level: "beginner"
order: 1
tags:
- markdown
- writing
- docs
---
# Markdown Basics
Markdown is a plain-text format for writing structured content.
If you forget a required field, Astro can report a validation error during development or build.
Common Zod Field Types
| Schema Type | Use For | Example |
|---|---|---|
z.string() |
Text | Title, description, role |
z.number() |
Numbers | Order, level, rating |
z.boolean() |
True/false values | Draft, featured, spoiler |
z.array(z.string()) |
Lists of text | Tags, skills, aliases |
z.enum([...]) |
Controlled choices | Status, difficulty, region type |
z.object({...}) |
Nested data | Attributes, coordinates, stats |
z.coerce.date() |
Dates from frontmatter | Published date, updated date |
Required, Optional, and Default Fields
A normal field is required:
title: z.string()
An optional field can be left out:
subtitle: z.string().optional()
A default field gets a fallback value:
tags: z.array(z.string()).default([])
Use defaults when you want the data to always be present, even if the author does not type it.
Lesson Schema Example
This schema works well for a tutorial or curriculum site:
const lessons = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
course: z.string(),
level: z.enum(['beginner', 'intermediate', 'advanced']),
order: z.number(),
estimatedTime: z.string().optional(),
hasExercise: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
Example lesson frontmatter:
---
title: "Starlight Sidebar Navigation"
description: "Learn how to organize sidebar groups and links."
course: "Astro Starlight"
level: "intermediate"
order: 4
estimatedTime: "35 minutes"
hasExercise: true
tags:
- astro
- starlight
- navigation
---
Character Schema Example
This schema works for RPG-style character pages or world bible character entries:
const characters = defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
type: z.literal('character'),
role: z.string(),
region: z.string(),
faction: z.string().optional(),
status: z.enum(['active', 'missing', 'dead', 'unknown']).default('active'),
attributes: z.object({
strength: z.number(),
dexterity: z.number(),
constitution: z.number(),
intelligence: z.number(),
wisdom: z.number(),
charisma: z.number(),
}).optional(),
skills: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
}),
});
Example character frontmatter:
---
title: "Veritas Miraculo"
description: "Heir apparent of House Miraculo and dangerous political rival."
type: "character"
role: "Rival heir apparent"
region: "Europe"
faction: "House Miraculo"
status: "active"
attributes:
strength: 10
dexterity: 14
constitution: 12
intelligence: 16
wisdom: 15
charisma: 18
skills:
- Diplomacy
- Strategy
- Court politics
tags:
- Europe
- House Miraculo
- Rival
---
Region Schema Example
const regions = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
type: z.literal('region'),
parentRegion: z.string().optional(),
governmentType: z.string().optional(),
technologyLevel: z.string().optional(),
magicLevel: z.string().optional(),
population: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
});
Example region frontmatter:
---
title: "Austral Confluence"
description: "A vast spatial anomaly where magic and geography behave differently."
type: "region"
governmentType: "Mixed local powers and external interests"
technologyLevel: "Variable"
magicLevel: "High"
population: "Unknown"
tags:
- confluence
- magic
- frontier
---
Faction Schema Example
const factions = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
type: z.literal('faction'),
region: z.string().optional(),
alignment: z.enum(['heroic', 'neutral', 'villainous', 'mixed', 'unknown']).default('unknown'),
scope: z.enum(['local', 'regional', 'global', 'interregional']).default('local'),
publicStatus: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
});
Location Schema Example
const locations = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
type: z.literal('location'),
region: z.string(),
locationType: z.enum([
'city',
'district',
'facility',
'wilderness',
'landmark',
'portal',
'settlement',
'other'
]),
securityLevel: z.enum(['low', 'moderate', 'high', 'restricted', 'unknown']).default('unknown'),
tags: z.array(z.string()).default([]),
}),
});
Controlled choices are useful because they prevent accidental variants such as High, high security, HIGH, and secure all meaning the same thing.
Complete content.config.ts Example
import { defineCollection, z } from 'astro:content';
const lessons = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
course: z.string(),
level: z.enum(['beginner', 'intermediate', 'advanced']),
order: z.number(),
estimatedTime: z.string().optional(),
hasExercise: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
const characters = defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
type: z.literal('character'),
role: z.string(),
region: z.string(),
faction: z.string().optional(),
status: z.enum(['active', 'missing', 'dead', 'unknown']).default('active'),
skills: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
}),
});
const regions = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
type: z.literal('region'),
parentRegion: z.string().optional(),
governmentType: z.string().optional(),
technologyLevel: z.string().optional(),
magicLevel: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = {
lessons,
characters,
regions,
};
Reading a Collection with getCollection()
Inside an Astro page, you can load collection entries.
---
import { getCollection } from 'astro:content';
const lessons = await getCollection('lessons');
const sortedLessons = lessons.sort((a, b) => {
return a.data.order - b.data.order;
});
---
<h1>Lessons</h1>
<ul>
{sortedLessons.map((lesson) => (
<li>
<a href={`/lessons/${lesson.id}/`}>
{lesson.data.title}
</a>
</li>
))}
</ul>
The data property contains the validated frontmatter.
Filtering Entries
You can filter entries by frontmatter values.
---
import { getCollection } from 'astro:content';
const beginnerLessons = await getCollection('lessons', ({ data }) => {
return data.level === 'beginner';
});
---
This is useful for creating course pages, tag pages, region pages, faction pages, or character lists.
Generating Pages from a Collection
To generate one page per entry, create a dynamic route.
src/pages/lessons/[...slug].astro
Example:
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const lessons = await getCollection('lessons');
return lessons.map((lesson) => ({
params: { slug: lesson.id },
props: { lesson },
}));
}
const { lesson } = Astro.props;
const { Content } = await render(lesson);
---
<article>
<h1>{lesson.data.title}</h1>
<p>{lesson.data.description}</p>
<Content />
</article>
Using Collection Data for Cards
Collection metadata is useful for index pages.
---
import { getCollection } from 'astro:content';
const characters = await getCollection('characters');
---
<h1>Characters</h1>
<div class="card-grid">
{characters.map((character) => (
<article class="card">
<h2>{character.data.title}</h2>
<p>{character.data.description}</p>
<p><strong>Role:</strong> {character.data.role}</p>
<p><strong>Region:</strong> {character.data.region}</p>
</article>
))}
</div>
This is why consistent schemas matter. If every character has the same fields, you can create reliable character cards.
Reusable Schema Pieces
If several collections use the same fields, create reusable schema pieces.
const baseEntry = {
title: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).default([]),
};
const characters = defineCollection({
schema: z.object({
...baseEntry,
type: z.literal('character'),
role: z.string(),
region: z.string(),
}),
});
const regions = defineCollection({
schema: z.object({
...baseEntry,
type: z.literal('region'),
governmentType: z.string().optional(),
}),
});
This keeps your schema file shorter and more consistent.
Drafts and Publishing
Add a draft field if you want to hide unfinished entries.
draft: z.boolean().default(false)
Then filter drafts out:
const lessons = await getCollection('lessons', ({ data }) => {
return data.draft !== true;
});
Dates
Use dates for posts, changelogs, updates, or published lessons.
publishedDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
Example frontmatter:
---
title: "Astro Content Collections"
publishedDate: 2026-05-13
updatedDate: 2026-05-13
---
z.coerce.date() is helpful because frontmatter dates often begin as strings.
Images
Astro schemas can also work with image helpers for local images. This is useful for thumbnails, portraits, maps, and lesson screenshots.
import { defineCollection, z } from 'astro:content';
const characters = defineCollection({
schema: ({ image }) => z.object({
title: z.string(),
portrait: image().optional(),
}),
});
Use image fields when you want Astro to understand an image as part of structured content.
References Between Entries
Astro supports references between collections. This can be useful when one type of content points to another.
import { defineCollection, reference, z } from 'astro:content';
const characters = defineCollection({
schema: z.object({
title: z.string(),
homeRegion: reference('regions').optional(),
}),
});
const regions = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
}),
});
export const collections = {
characters,
regions,
};
References are useful for relationships such as character-to-region, character-to-faction, location-to-region, and storyline-to-character.
Common Content Collection Errors
Wrong config file location
Correct:
src/content.config.ts
Usually wrong:
src/content/content.config.ts
Collection name does not match folder name
Folder:
src/content/characters/
Config:
export const collections = {
characterProfiles,
};
Problem:
The collection key should usually match the folder you intend to query.
Missing required frontmatter
Schema requires:
title: z.string()
But entry has no title.
Wrong type
Schema expects:
order: z.number()
But frontmatter has:
order: "first"
Best Practices
- Start with simple schemas and add complexity later.
- Use
z.enum()for controlled categories such as level, status, type, and security level. - Use
.optional()for fields that genuinely may not exist. - Use
.default()for fields you want to safely use everywhere. - Keep names consistent:
regionshould not also bearea,realm, andlocationRegionunless they mean different things. - Do not overbuild the schema before you understand the content.
- Use separate collections when different content types need very different metadata.
Recommended Collections for a Lesson Site
src/content/
lessons/
courses/
exercises/
references/
cheatsheets/
Suggested schema fields:
lessons:
title
description
course
level
order
estimatedTime
hasExercise
tags
courses:
title
description
level
order
status
tags
exercises:
title
lesson
difficulty
starterCode
solutionAvailable
tags
Recommended Collections for a World Bible Site
src/content/
regions/
characters/
factions/
locations/
storylines/
timelines/
references/
Suggested schema fields:
characters:
title
type
role
region
faction
status
skills
tags
regions:
title
type
parentRegion
governmentType
technologyLevel
magicLevel
tags
factions:
title
type
region
alignment
scope
publicStatus
tags
locations:
title
type
region
locationType
securityLevel
tags
storylines:
title
type
region
status
mainCharacters
themes
tags
Mini Project: Build a Character Collection
Create this folder:
src/content/characters/
Create this config:
import { defineCollection, z } from 'astro:content';
const characters = defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
role: z.string(),
region: z.string(),
status: z.enum(['active', 'missing', 'dead', 'unknown']).default('active'),
tags: z.array(z.string()).default([]),
}),
});
export const collections = {
characters,
};
Create this entry:
---
title: "Example Character"
description: "A sample character for testing the schema."
role: "Investigator"
region: "Atlantic Compact"
status: "active"
tags:
- sample
- character
---
# Example Character
## Identity
## Background
## Story Role
Then create an index page that loads and displays the character collection.
Cheat Sheet
| Need | Use |
|---|---|
| Define a collection | defineCollection() |
| Create a schema | z.object({...}) |
| Required text | z.string() |
| Optional text | z.string().optional() |
| Default list | z.array(z.string()).default([]) |
| Controlled choices | z.enum(['one', 'two']) |
| Read all entries | getCollection('name') |
| Render entry body | render(entry) |
| Generate pages | getStaticPaths() |
Simple Rule of Thumb
If the page is one of many similar pages:
use a collection.
If the frontmatter should follow rules:
use a schema.
If you need index pages, cards, filters, sorting, or related links:
use collection data.
If the page is a one-off landing page:
a normal Astro page may be enough.
What to Learn Next
- Dynamic routes with collection entries
- Filtering and sorting collection data
- Cross-collection references
- Building character cards from schema data
- Building course indexes from lesson collections
- Adding images to content schemas
- Using Starlight with structured collections