Astro Dynamic Routes and Collection Indexes Tutorial

A practical guide to generating pages and indexes from Astro Content Collections using getCollection(), getStaticPaths(), and render().

What You Are Learning

This tutorial explains how to move from hand-written pages to generated pages and indexes.

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

NeedUse
Load entriesgetCollection()
Generate routesgetStaticPaths()
Render entry bodyrender(entry)
Nested dynamic routes[...slug].astro
Sort entries.sort()
Filter entries.filter()
Unique tagsnew 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.