Astro Content Collections and Schemas

A practical tutorial for using Astro Content Collections and schemas to organize lessons, world bible pages, character sheets, factions, regions, locations, and reference entries.

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

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