What You Are Learning
This tutorial explains how to move from hand-written pages to generated pages and indexes.
- Load content entries with
getCollection(). - Generate one page per entry with
getStaticPaths(). - Render Markdown/MDX body content with
render(). - Create collection index pages.
- Sort and filter entries.
Why Dynamic Routes Matter
If you only have five pages, writing each page by hand is fine. If you have dozens or hundreds of lessons, characters, regions, or reference entries, dynamic routes help you generate consistent pages from content files.
Content files
↓
Collection schema
↓
Dynamic route
↓
Generated pages
↓
Index pages and filters
Example Content Structure
src/content/
lessons/
markdown-basics.md
mdx-basics.md
content-collections.md
characters/
example-character.md
rival-heir.md
regions/
europe.md
austral-confluence.md
Example content.config.ts
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([]),
draft: z.boolean().default(false),
}),
});
const characters = defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
role: z.string(),
region: z.string(),
faction: z.string().optional(),
status: z.enum(['active', 'missing', 'dead', 'unknown']).default('active'),
tags: z.array(z.string()).default([]),
}),
});
export const collections = {
lessons,
characters,
};
Create a Lesson Entry
src/content/lessons/markdown-basics.md
---
title: "Markdown Basics"
description: "Learn headings, lists, links, and code blocks."
level: "beginner"
order: 1
tags:
- markdown
- writing
draft: false
---
# Markdown Basics
Markdown is a simple writing format for structured content.
Create a Lesson Index Page
Create:
src/pages/lessons/index.astro
---
import { getCollection } from 'astro:content';
const lessons = await getCollection('lessons', ({ data }) => {
return data.draft !== true;
});
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>
<p>{lesson.data.description}</p>
</li>
))}
</ul>
Create Dynamic Lesson Pages
Create:
src/pages/lessons/[...slug].astro
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const lessons = await getCollection('lessons', ({ data }) => {
return data.draft !== true;
});
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>
How getStaticPaths Works
getStaticPaths() tells Astro which pages to create at build time.
return lessons.map((lesson) => ({
params: { slug: lesson.id },
props: { lesson },
}));
The params define the URL. The props pass the entry into the page template.
Understanding [...slug].astro
The file name [...slug].astro creates a dynamic route that can handle nested paths.
src/content/lessons/markdown-basics.md
→ /lessons/markdown-basics/
src/content/lessons/beginner/yaml-basics.md
→ /lessons/beginner/yaml-basics/
Create a Character Index Page
Create:
src/pages/characters/index.astro
---
import { getCollection } from 'astro:content';
const characters = await getCollection('characters');
const activeCharacters = characters
.filter((character) => character.data.status === 'active')
.sort((a, b) => a.data.title.localeCompare(b.data.title));
---
<h1>Characters</h1>
<div class="card-grid">
{activeCharacters.map((character) => (
<article class="card">
<h2>
<a href={`/characters/${character.id}/`}>
{character.data.title}
</a>
</h2>
<p>{character.data.description}</p>
<p><strong>Role:</strong> {character.data.role}</p>
<p><strong>Region:</strong> {character.data.region}</p>
</article>
))}
</div>
Create Dynamic Character Pages
Create:
src/pages/characters/[...slug].astro
---
import { getCollection, render } from 'astro:content';
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>}
<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>
<Content />
</article>
Sorting and Filtering Entries
// Sort lessons by order
const sortedLessons = lessons.sort((a, b) => a.data.order - b.data.order);
// Sort characters alphabetically
const sortedCharacters = characters.sort((a, b) => {
return a.data.title.localeCompare(b.data.title);
});
// Filter by difficulty level
const beginnerLessons = lessons.filter((lesson) => lesson.data.level === 'beginner');
// Filter by tag
const markdownLessons = lessons.filter((lesson) => lesson.data.tags.includes('markdown'));
Create a Tag Index
const lessons = await getCollection('lessons');
const tags = [...new Set(
lessons.flatMap((lesson) => lesson.data.tags)
)].sort();
Then render a tag list:
<ul>
{tags.map((tag) => (
<li>
<a href={`/tags/${tag}/`}>{tag}</a>
</li>
))}
</ul>
Generate Tag Pages
Create:
src/pages/tags/[tag].astro
---
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const lessons = await getCollection('lessons');
const tags = [...new Set(
lessons.flatMap((lesson) => lesson.data.tags)
)];
return tags.map((tag) => ({
params: { tag },
props: { tag },
}));
}
const { tag } = Astro.props;
const lessons = await getCollection('lessons', ({ data }) => {
return data.tags.includes(tag);
});
---
<h1>Tag: {tag}</h1>
<ul>
{lessons.map((lesson) => (
<li>
<a href={`/lessons/${lesson.id}/`}>
{lesson.data.title}
</a>
</li>
))}
</ul>
Dynamic Routes and Starlight
Starlight documentation pages normally live in src/content/docs/. Custom dynamic routes live in src/pages/. You can use both in the same project.
src/content/docs/
getting-started.md
guides/sidebar.md
src/content/characters/
example-character.md
src/pages/characters/
index.astro
[...slug].astro
Common Mistakes
Wrong collection name
Bad:
getCollection('lesson')
Good:
getCollection('lessons')
Forgetting render()
Use render(entry) when you need to display the Markdown/MDX body.
Broken generated links
Check that your href matches the route created by your dynamic page.
Cheat Sheet
| Need | Use |
|---|---|
| Load entries | getCollection() |
| Generate routes | getStaticPaths() |
| Render entry body | render(entry) |
| Nested dynamic routes | [...slug].astro |
| Sort entries | .sort() |
| Filter entries | .filter() |
| Unique tags | new Set() |
Simple Rule of Thumb
Use collections for repeated content.
Use index pages to list entries.
Use dynamic routes to generate entry pages.
Use schemas to keep data consistent.
Use filters for categories and tags.
Use render() to display Markdown/MDX body content.