What You Are Building
This tutorial shows how to build a character-sheet system for a world bible, campaign guide, fiction project, or RPG-inspired setting.
The system includes:
- Structured character metadata in YAML frontmatter.
- RPG-style attributes and skills.
- Character prose sections.
- A character schema for validation.
- A reusable character stat component.
- A character index page.
- Filters by role, faction, region, or status.
The Big Picture
Character Markdown or MDX file
↓
YAML frontmatter stores structured sheet data
↓
Markdown body stores biography and story details
↓
Astro schema validates the data
↓
Components render stat blocks and cards
↓
Indexes list and filter characters
The main idea is to separate structured data from prose. Stats, roles, factions, tags, and status belong in frontmatter. Background, personality, relationships, and arc belong in the page body.
Recommended Character File Structure
src/content/characters/
character-template.md
example-character.md
veritas-miraculo.md
aurelian-rassendyll.md
src/pages/characters/
index.astro
[...slug].astro
src/components/
CharacterCard.astro
CharacterStats.astro
CharacterSheet.astro
Basic Character Frontmatter
---
title: "Example Character"
description: "A sample character used to test the character sheet system."
type: "character"
role: "Investigator"
region: "Atlantic Compact"
faction: "Independent"
species: "Human"
status: "active"
tags:
- investigator
- example
- character
---
RPG-Style Attribute Frontmatter
---
title: "Example Character"
role: "Investigator"
region: "Atlantic Compact"
faction: "Independent"
status: "active"
attributes:
strength: 10
dexterity: 13
constitution: 12
intelligence: 16
wisdom: 14
charisma: 11
skills:
- Investigation
- Diplomacy
- Technology
- Research
---
This uses a familiar six-attribute structure, but you can rename or replace the attributes for your own system.
Character Page Body Template
# Example Character
## Identity
## Appearance
## Background
## Role in the Setting
## Personality
## Goals
## Flaws
## Relationships
## Secrets
## Abilities and Equipment
## Story Arc
## Notes
The frontmatter is the sheet data. The body is the character article.
Character Schema
Create or edit:
src/content.config.ts
import { defineCollection, z } from 'astro:content';
const characters = defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
type: z.literal('character').default('character'),
role: z.string(),
region: z.string(),
faction: z.string().optional(),
species: z.string().optional(),
status: z.enum(['active', 'missing', 'dead', 'retired', '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([]),
}),
});
export const collections = { characters };
The schema catches missing or incorrectly typed character data.
CharacterStats Component
Create:
src/components/CharacterStats.astro
---
const { attributes } = Astro.props;
---
{attributes && (
<section class="character-stats">
<h2>Attributes</h2>
<dl class="stat-grid">
<dt>Strength</dt>
<dd>{attributes.strength}</dd>
<dt>Dexterity</dt>
<dd>{attributes.dexterity}</dd>
<dt>Constitution</dt>
<dd>{attributes.constitution}</dd>
<dt>Intelligence</dt>
<dd>{attributes.intelligence}</dd>
<dt>Wisdom</dt>
<dd>{attributes.wisdom}</dd>
<dt>Charisma</dt>
<dd>{attributes.charisma}</dd>
</dl>
</section>
)}
CharacterCard Component
Create:
src/components/CharacterCard.astro
---
const { character, href } = Astro.props;
---
<article class="character-card">
<h2><a href={href}>{character.data.title}</a></h2>
{character.data.description && <p>{character.data.description}</p>}
<dl>
<dt>Role</dt>
<dd>{character.data.role}</dd>
<dt>Region</dt>
<dd>{character.data.region}</dd>
<dt>Status</dt>
<dd>{character.data.status}</dd>
</dl>
</article>
Dynamic Character Page
Create:
src/pages/characters/[...slug].astro
---
import { getCollection, render } from 'astro:content';
import CharacterStats from '../../components/CharacterStats.astro';
export async function getStaticPaths() {
const characters = await getCollection('characters');
return characters.map((character) => ({
params: { slug: character.id },
props: { character },
}));
}
const { character } = Astro.props;
const { Content } = await render(character);
---
<article>
<h1>{character.data.title}</h1>
{character.data.description && <p>{character.data.description}</p>}
<section>
<h2>Sheet Summary</h2>
<dl>
<dt>Role</dt>
<dd>{character.data.role}</dd>
<dt>Region</dt>
<dd>{character.data.region}</dd>
<dt>Faction</dt>
<dd>{character.data.faction ?? 'None listed'}</dd>
<dt>Status</dt>
<dd>{character.data.status}</dd>
</dl>
</section>
<CharacterStats attributes={character.data.attributes} />
<section>
<h2>Skills</h2>
<ul>
{character.data.skills.map((skill) => <li>{skill}</li>)}
</ul>
</section>
<Content />
</article>
Character Index Page
Create:
src/pages/characters/index.astro
---
import { getCollection } from 'astro:content';
import CharacterCard from '../../components/CharacterCard.astro';
const characters = await getCollection('characters');
const sortedCharacters = characters.sort((a, b) => {
return a.data.title.localeCompare(b.data.title);
});
---
<h1>Characters</h1>
<div class="character-grid">
{sortedCharacters.map((character) => (
<CharacterCard
character={character}
href={`/characters/${character.id}/`}
/>
))}
</div>
Filtering Characters
Filter by region:
const europeCharacters = characters.filter((character) => {
return character.data.region === 'Europe';
});
Filter by faction:
const houseCharacters = characters.filter((character) => {
return character.data.faction === 'House Valoire';
});
Filter by status:
const activeCharacters = characters.filter((character) => {
return character.data.status === 'active';
});
CSS for Character Sheets
.character-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.character-card,
.character-stats {
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.75rem;
padding: 1rem;
background: var(--sl-color-bg-nav);
}
.stat-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem 1rem;
}
Optional Derived Stats
You can calculate derived values in a component.
---
const { attributes } = Astro.props;
const toughness = attributes ? Math.floor((attributes.constitution - 10) / 2) + 10 : null;
const initiative = attributes ? Math.floor((attributes.dexterity - 10) / 2) : null;
---
Derived stats are useful for RPG-inspired sheets, but keep the rules simple and well documented.
Character Relationships
For simple projects, relationships can be prose:
## Relationships
- Ally: Example Ally
- Rival: Example Rival
- Patron: Example Patron
For structured projects, add relationship fields:
relationships:
allies:
- "Example Ally"
rivals:
- "Example Rival"
enemies:
- "Example Enemy"
Build Checklist
[ ] Character collection exists
[ ] Character schema validates required fields
[ ] Character template exists
[ ] CharacterStats component exists
[ ] CharacterCard component exists
[ ] Dynamic character route works
[ ] Character index page works
[ ] CSS supports cards and stat blocks
[ ] Filters work by region, faction, or status
[ ] npm run build succeeds
Common Mistakes
Putting everything in the body
Stats, tags, roles, regions, and statuses are better in frontmatter so they can be sorted and filtered.
Overcomplicating the first schema
Start with a simple schema. Add fields once you know you will use them.
Mixing similar field names
Do not use house, faction, group, and organization randomly if they mean the same thing.
No index page
The character system becomes much more useful when readers can browse all characters in one place.
Simple Rule of Thumb
Use frontmatter for sheet data.
Use Markdown body for story and personality.
Use schemas for consistency.
Use components for repeated visual blocks.
Use indexes for browsing.
Use filters for worldbuilding scale.