Project: Documentation Site
Documentation sites are one of Astro’s strongest use cases: content lives as Markdown or MDX, the framework renders it to static HTML with zero client-side JavaScript by default, and you sprinkle interactivity only where you need it. In this project you will build a docs site with a generated sidebar, MDX content, full-text search powered by Pagefind, and a per-page table of contents. The result loads instantly, indexes for free at build time, and scales to hundreds of pages without a runtime.
Project structure
A docs site is just a content collection plus a layout. Organize the project so content is separate from presentation:
src/
content/
docs/
getting-started.mdx
guides/
deploy.mdx
config.ts
layouts/
DocsLayout.astro
components/
Sidebar.astro
TableOfContents.astro
pages/
docs/
[...slug].astro
astro.config.mjs
Defining the content collection
Content collections give you type-safe frontmatter validated against a Zod schema. Define the schema once and Astro will flag any page with a missing or malformed field at build time.
// src/content/config.ts
import { defineCollection, z } from "astro:content";
const docs = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string().optional(),
order: z.number().default(0),
section: z.string().default("Guides"),
}),
});
export const collections = { docs };
Add the MDX integration so pages can mix Markdown with components:
npx astro add mdx
Use
.mdxonly for pages that need embedded components or JSX expressions. Plain.mdrenders slightly faster and keeps content portable. Mixing both in one collection is fine.
Rendering pages with a dynamic route
A single catch-all route renders every entry in the collection. getStaticPaths enumerates the pages at build time, and render() returns both the component and the extracted headings array used by the table of contents.
---
// src/pages/docs/[...slug].astro
import { getCollection } from "astro:content";
import DocsLayout from "../../layouts/DocsLayout.astro";
export async function getStaticPaths() {
const entries = await getCollection("docs");
return entries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content, headings } = await entry.render();
---
<DocsLayout entry={entry} headings={headings}>
<Content />
</DocsLayout>
Building the sidebar
The sidebar groups entries by their section frontmatter and sorts by order. Because this runs at build time, there is no client-side cost: it ships as plain HTML and CSS.
---
// src/components/Sidebar.astro
import { getCollection } from "astro:content";
const entries = await getCollection("docs");
const groups = new Map<string, typeof entries>();
for (const entry of entries.sort((a, b) => a.data.order - b.data.order)) {
const key = entry.data.section;
groups.set(key, [...(groups.get(key) ?? []), entry]);
}
const currentSlug = Astro.url.pathname.replace(/^\/docs\/|\/$/g, "");
---
<nav aria-label="Docs navigation">
{[...groups].map(([section, items]) => (
<details open>
<summary>{section}</summary>
<ul>
{items.map((item) => (
<li>
<a
href={`/docs/${item.slug}`}
aria-current={item.slug === currentSlug ? "page" : undefined}
>
{item.data.title}
</a>
</li>
))}
</ul>
</details>
))}
</nav>
Per-page table of contents
Astro extracts a headings array from each rendered page. Each entry has depth, slug, and text, which is everything you need to build an in-page TOC. Filter to h2 and h3 to keep it readable.
---
// src/components/TableOfContents.astro
import type { MarkdownHeading } from "astro";
const { headings } = Astro.props as { headings: MarkdownHeading[] };
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
<aside aria-label="On this page">
<ul>
{toc.map((h) => (
<li style={`margin-left: ${(h.depth - 2) * 12}px`}>
<a href={`#${h.slug}`}>{h.text}</a>
</li>
))}
</ul>
</aside>
Wire the pieces together in the layout:
---
// src/layouts/DocsLayout.astro
import Sidebar from "../components/Sidebar.astro";
import TableOfContents from "../components/TableOfContents.astro";
const { entry, headings } = Astro.props;
---
<html lang="en">
<head>
<title>{entry.data.title}</title>
<meta name="description" content={entry.data.description} />
</head>
<body>
<Sidebar />
<main><slot /></main>
<TableOfContents headings={headings} />
</body>
</html>
Adding search with Pagefind
Pagefind indexes the static HTML after the build, so it works with any content and adds no runtime framework. Install it and chain it onto the build command:
npm install pagefind @pagefind/default-ui
{
"scripts": {
"build": "astro build && pagefind --site dist"
}
}
Output:
Running Pagefind v1...
Indexed 142 pages
Indexed 4196 words
Finished in 0.41 seconds
Mount the search UI on the client. This is the one island in the site — it hydrates only when the page loads, while everything else stays static HTML.
---
// src/components/Search.astro
---
<div id="search"></div>
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script>
// @ts-expect-error - generated at build time
import { PagefindUI } from "@pagefind/default-ui";
window.addEventListener("DOMContentLoaded", () => {
new PagefindUI({ element: "#search", showImages: false });
});
</script>
Pagefind only indexes the
dist/output, so search will not work withastro dev. Runnpm run build && npm run previewto test it locally.
Best practices
- Keep all docs in one content collection with a Zod schema so broken frontmatter fails the build, not production.
- Drive sidebar grouping and ordering from frontmatter (
section,order) instead of hardcoding navigation. - Add
idanchors to headings (Astro does this automatically) so the TOC and deep links work without extra code. - Ship the search box as an island with a
client:*-style script and keep the rest of the page zero-JS. - Run Pagefind as a post-build step in the same
buildscript so the index never drifts from the content. - Set
<meta name="description">from collection frontmatter for clean SEO on every generated page.