Build a documentation site with Astro and Markdown
By Flavio Copes
Build a fast documentation site with Astro, Markdown collections, generated navigation, previous and next links, search, and a sitemap.
A documentation site is a perfect project for Astro.
Most of the content is text. You want fast pages, clean URLs, good search, and as little client-side JavaScript as possible.
Astro gives you all of that.
In this tutorial we will build a small documentation site from scratch. The content will live in Markdown files. Astro will generate one static page for every file.
We will also add:
- validated frontmatter
- generated sidebar navigation
- previous and next links
- a table of contents
- syntax highlighting
- static search
- a sitemap
The result will be small, fast, and easy to maintain.
We will not build a documentation framework. We will build a documentation site.
That distinction matters.
Create the Astro project
Start by creating a new Astro project:
npm create astro@latest product-docs
Choose the empty template when the installer asks.
Then enter the project folder:
cd product-docs
npm install
Start the development server:
npm run dev
Astro prints the local URL in the terminal. Open it in your browser.
At this point you have a basic Astro site.
Let’s turn it into documentation.
Decide the content structure first
Documentation becomes difficult when every page invents its own structure.
Before writing components, decide what every document needs.
For this project, each page will have:
- a title
- a description
- an order number
- a group
- an optional draft flag
The title appears at the top of the page.
The description explains what the reader will learn. We can also use it in the page metadata.
The order controls the sidebar and previous or next links.
The group lets us split the sidebar into sections such as “Start here” and “Guides.”
The draft flag lets us keep unfinished pages out of the production site.
Here is the folder structure we will build:
src/
content/
docs/
getting-started.md
installation.md
first-project.md
deployment.md
layouts/
DocsLayout.astro
pages/
docs/
[...slug].astro
index.astro
index.astro
content.config.ts
The Markdown files hold the content.
The content configuration validates them.
The dynamic Astro page turns each Markdown file into a URL.
The layout provides the shared header, sidebar, and page styles.
This is the whole architecture.
Create the documentation collection
Astro content collections give us a structured way to work with Markdown files.
Create src/content.config.ts:
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
const docs = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './src/content/docs',
}),
schema: z.object({
title: z.string(),
description: z.string(),
order: z.number(),
group: z.string(),
draft: z.boolean().optional(),
}),
})
export const collections = { docs }
The glob() loader reads every Markdown file inside src/content/docs.
The **/*.md pattern also includes files in nested folders. We are not using nested folders yet, but we will not need to change the loader if we add them later.
The schema is more important than it might look.
Without a schema, a typo in frontmatter becomes an undefined value somewhere in the interface. You discover it when the page looks wrong.
With a schema, Astro stops the build and tells you which file is invalid.
That is much better.
Documentation grows over time. A strict content shape prevents small inconsistencies from spreading across hundreds of pages.
Add the first Markdown pages
Create the src/content/docs folder.
Then add getting-started.md:
---
title: Getting started
description: Learn what Acme does and create your first project.
order: 1
group: Start here
---
Acme helps you publish small websites without configuring a server.
## Create an account
Open the dashboard and choose **Create account**.
## Create a project
Choose **New project**, enter a name, and press **Create**.
Add installation.md:
---
title: Install the CLI
description: Install the Acme command line tool and log in.
order: 2
group: Start here
---
Install the CLI using npm:
```bash
npm install -g acme
```
Then log in:
```bash
acme login
```
Add first-project.md:
---
title: Create your first project
description: Create a local project and publish it.
order: 3
group: Guides
---
Create a new project:
```bash
acme create my-site
```
Move into the folder and publish it:
```bash
cd my-site
acme deploy
```
Finally, add deployment.md:
---
title: Deployment
description: Deploy a project and inspect the result.
order: 4
group: Guides
---
Run the deploy command from your project folder:
```bash
acme deploy
```
The command prints the public URL when the upload finishes.
These pages are intentionally small.
Documentation should not try to impress the reader. It should help the reader finish a task.
Notice that every code block has a language.
Astro syntax-highlights Markdown code blocks at build time. We do not need to load a syntax-highlighting library in the browser.
Generate a page for every document
Now we need to turn the collection into pages.
Create src/pages/docs/[...slug].astro:
---
import { getCollection, render } from 'astro:content'
import DocsLayout from '../../layouts/DocsLayout.astro'
export async function getStaticPaths() {
const docs = await getCollection('docs', ({ data }) => !data.draft)
return docs.map((doc) => ({
params: { slug: doc.id },
props: { doc },
}))
}
const { doc } = Astro.props
const { Content, headings } = await render(doc)
---
<DocsLayout doc={doc} headings={headings}>
<Content />
</DocsLayout>
The rest parameter in [...slug].astro lets one page handle every document URL.
getStaticPaths() runs during the build.
It loads all documents, removes drafts, and returns one path for each entry.
If the entry ID is getting-started, Astro creates:
/docs/getting-started/
If you later create guides/authentication.md, its ID includes the folder and Astro creates:
/docs/guides/authentication/
We pass the document as a prop.
Then render(doc) gives us two things we need:
Content, the rendered Markdown componentheadings, a list of headings in the document
We pass both to the layout.
This follows a useful pattern:
The route loads the content. The layout decides how to display it.
Keeping those jobs separate makes both files easier to understand.
Build the documentation layout
Create src/layouts/DocsLayout.astro.
Start with the frontmatter:
---
import { getCollection } from 'astro:content'
const { doc, headings } = Astro.props
const docs = await getCollection('docs', ({ data }) => !data.draft)
const orderedDocs = docs.sort((a, b) => a.data.order - b.data.order)
const currentIndex = orderedDocs.findIndex((item) => item.id === doc.id)
const previous = orderedDocs[currentIndex - 1]
const next = orderedDocs[currentIndex + 1]
const groups = Map.groupBy(orderedDocs, (item) => item.data.group)
---
The layout loads the same collection to build navigation.
We sort by the order value from frontmatter. This gives us a stable reading order without depending on filenames.
Then we find the current page.
The entry before it becomes the previous link. The entry after it becomes the next link.
Finally, Map.groupBy() separates the entries by their group value.
If you need to support an older JavaScript runtime, you can replace Map.groupBy() with a small reduce() call. Astro runs this code at build time, so use the Node version configured for your build.
Now add the page shell:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{doc.data.title} - Acme Docs</title>
<meta name="description" content={doc.data.description} />
</head>
<body>
<header>
<a href="/docs/">Acme Docs</a>
</header>
<div class="docs-shell">
<aside>
<nav aria-label="Documentation">
{
Array.from(groups).map(([group, items]) => (
<section>
<h2>{group}</h2>
<ul>
{items.map((item) => (
<li>
<a
href={`/docs/${item.id}/`}
aria-current={item.id === doc.id ? 'page' : undefined}
>
{item.data.title}
</a>
</li>
))}
</ul>
</section>
))
}
</nav>
</aside>
<main>
<article data-pagefind-body>
<p class="eyebrow">{doc.data.group}</p>
<h1>{doc.data.title}</h1>
<p class="description">{doc.data.description}</p>
<slot />
</article>
</main>
</div>
</body>
</html>
This is a normal Astro layout. The <slot /> is where the rendered Markdown appears.
The aria-current="page" attribute identifies the active sidebar link for assistive technology. We can also use it as a CSS selector.
The data-pagefind-body attribute will matter when we add search. It tells Pagefind to index the article, not the navigation repeated on every page.
Add a table of contents
Long documentation pages need local navigation.
The headings value from render(doc) contains every Markdown heading, including its depth, text, and generated slug.
Add this inside the layout, after the article:
{
headings.length > 0 && (
<nav class="toc" aria-label="On this page">
<h2>On this page</h2>
<ul>
{headings
.filter((heading) => heading.depth === 2)
.map((heading) => (
<li>
<a href={`#${heading.slug}`}>{heading.text}</a>
</li>
))}
</ul>
</nav>
)
}
We only show level-two headings.
That keeps the table of contents short. A deeply nested table often becomes harder to scan than the page itself.
Astro automatically adds matching IDs to Markdown headings. A heading named Create an account receives a slug such as create-an-account.
We link to that slug.
No client-side JavaScript is needed.
Add previous and next links
Documentation is often read in order.
Add this after <slot />, still inside the article:
<nav class="page-links" aria-label="Documentation pages">
<div>
{previous && <a href={`/docs/${previous.id}/`}>← {previous.data.title}</a>}
</div>
<div>
{next && <a href={`/docs/${next.id}/`}>{next.data.title} →</a>}
</div>
</nav>
The first page has no previous link.
The last page has no next link.
Every page between them gets both.
This is why we added the order field to frontmatter. Navigation, sidebar order, and the learning path all use the same source of truth.
Add basic styles
Documentation does not need a complicated design.
It needs readable text, visible navigation, and code that fits.
Add a global style block at the bottom of the layout:
<style is:global>
:root {
font-family: system-ui, sans-serif;
color: #1c1c1c;
background: #fff;
}
body {
margin: 0;
}
header {
padding: 1rem 2rem;
border-bottom: 1px solid #ddd;
}
.docs-shell {
display: grid;
grid-template-columns: 16rem minmax(0, 48rem) 14rem;
gap: 2rem;
max-width: 88rem;
margin: 0 auto;
padding: 2rem;
}
aside {
border-right: 1px solid #ddd;
}
aside h2,
.toc h2 {
font-size: 0.8rem;
text-transform: uppercase;
}
nav ul {
padding: 0;
list-style: none;
}
nav li {
margin: 0.5rem 0;
}
a {
color: #0759c7;
}
a[aria-current='page'] {
font-weight: 700;
}
article {
line-height: 1.7;
}
article img {
max-width: 100%;
}
article pre {
overflow-x: auto;
padding: 1rem;
}
.description {
font-size: 1.15rem;
color: #555;
}
.page-links {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: 4rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}
.page-links div:last-child {
text-align: right;
}
@media (max-width: 900px) {
.docs-shell {
grid-template-columns: 1fr;
}
aside {
border-right: 0;
border-bottom: 1px solid #ddd;
}
.toc {
display: none;
}
}
</style>
Notice the minmax(0, 48rem) value in the grid.
The zero prevents long content, especially code blocks, from forcing the column wider than the available space.
The overflow-x: auto rule lets a long code line scroll without breaking the whole layout.
These are small details, but documentation contains lots of code. Test narrow screens early.
Create the documentation homepage
The dynamic route creates individual pages, but we also want /docs/.
Create src/pages/docs/index.astro:
---
import { getCollection } from 'astro:content'
const docs = await getCollection('docs', ({ data }) => !data.draft)
const orderedDocs = docs.sort((a, b) => a.data.order - b.data.order)
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>Acme Documentation</title>
<meta
name="description"
content="Learn how to install, configure, and deploy Acme."
/>
</head>
<body>
<main>
<h1>Acme Documentation</h1>
<p>Start with the first guide and publish your first project.</p>
<ol>
{
orderedDocs.map((doc) => (
<li>
<a href={`/docs/${doc.id}/`}>{doc.data.title}</a>
<p>{doc.data.description}</p>
</li>
))
}
</ol>
</main>
</body>
</html>
We reuse the collection and the same order.
The homepage now doubles as a complete documentation index.
You could extract another layout for this page. I would wait until a second page needs the same structure.
My advice is to remove duplication when it appears, not before.
Add static search with Pagefind
A sidebar works when the site is small.
Search becomes essential when the documentation grows.
Pagefind is a good match because it indexes the generated HTML after Astro builds it. The search runs entirely in the browser. There is no search server to maintain.
Install it:
npm install -D pagefind
Update the build script in package.json:
{
"scripts": {
"dev": "astro dev",
"build": "astro build && pagefind --site dist"
}
}
Astro builds the HTML first.
Then Pagefind reads dist and writes its search bundle into dist/pagefind.
Add the Pagefind component files to the <head> in DocsLayout.astro:
<link href="/pagefind/pagefind-component-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-component-ui.js" type="module"></script>
Then add a search box below the site name in the header:
<pagefind-searchbox></pagefind-searchbox>
Run a production build:
npm run build
Search will not work in the normal Astro development server because the index does not exist yet.
To build the index and preview the result, run:
npx pagefind --site dist --serve
Remember the data-pagefind-body attribute we added to the article.
Once Pagefind finds that attribute, it indexes those marked regions and ignores pages without them. This prevents the repeated sidebar from polluting every result.
Add data-pagefind-body to the main content on the documentation homepage too if you want that page in search.
Add a sitemap
Search helps people already on the site.
A sitemap helps search engines discover every documentation page.
Install the official Astro sitemap integration:
npx astro add sitemap
The command adds the package and updates astro.config.mjs.
Make sure the config includes the public site URL:
import { defineConfig } from 'astro/config'
import sitemap from '@astrojs/sitemap'
export default defineConfig({
site: 'https://docs.acme.com',
integrations: [sitemap()],
})
Astro knows all the routes generated by getStaticPaths().
During the build, the integration adds them to the sitemap.
You do not need to maintain a separate list of documentation URLs.
Also add the sitemap to public/robots.txt:
User-agent: *
Allow: /
Sitemap: https://docs.acme.com/sitemap-index.xml
Use your real domain in both files.
Handle drafts
We already filtered drafts from the dynamic route:
const docs = await getCollection('docs', ({ data }) => !data.draft)
The layout and index page use the same filter.
To hide a page, add this to its frontmatter:
draft: true
The page disappears from the build, sidebar, index, previous and next links, search, and sitemap.
This is another benefit of using one collection everywhere.
Be careful not to load the collection in a new component without applying the filter. A small helper function can centralize this later if the query starts appearing in many places.
For this project, repeating one clear line is fine.
Check the final result
Before deploying, run:
npm run build
Then check:
- every Markdown file produces the expected URL
- the sidebar order matches the reading order
- the active page is visible
- previous and next links are correct
- heading links scroll to the right section
- code blocks work on a narrow screen
- draft pages do not appear
- search returns document content, not navigation text
- the sitemap includes the documentation pages
Also try breaking one frontmatter field on purpose:
order: first
The build should fail because order must be a number.
Put it back when you finish the test.
It is useful to see validation fail once. Then you know it is protecting the content.
Where to go from here
We now have a complete documentation site.
It has structured Markdown, generated pages, navigation, local page links, search, and a sitemap.
There are many things you can add later:
- versioned documentation
- multiple languages
- edit-on-GitHub links
- feedback buttons
- copy buttons for code blocks
- redirects for renamed pages
- automatically checked internal links
Do not add them because documentation sites are supposed to have them.
Add them when your readers need them.
The core should stay boring:
Markdown → collection → static Astro pages
That is the advantage of this setup.
Writers edit normal Markdown files. Astro validates and renders them. Pagefind indexes the final HTML. Your host serves static files.
No database.
No content API.
No search server.
No client-side application wrapped around a collection of documents.
Just documentation.
Want me to talk about your product? You can sponsor this site.
Related posts about astro: