Build a documentation site with Astro and Markdown

By

Build a fast documentation site with Astro, Markdown collections, generated navigation, previous and next links, search, and a sitemap.

~~~

A documentation site is a great fit for Astro. The content is mostly text, you want fast pages and clean URLs, and you do not want to ship a JavaScript app just to display some Markdown.

In this tutorial we build a small documentation site from scratch. The content lives in Markdown files. Astro turns each file into a static page. Along the way we add:

  • validated frontmatter
  • generated sidebar navigation
  • previous and next links
  • a table of contents
  • syntax highlighting
  • static search
  • a sitemap

We are not building a documentation framework with themes, plugins, and a config file. We are building one site, for one product, with only the pieces that site needs.

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

Before writing any component, decide what every page needs. If each page invents its own frontmatter, the sidebar and navigation code fill up with special cases.

For this project, each page will have:

  • a title
  • a description
  • an order number
  • a group
  • an optional draft flag

The title goes at the top of the page. The description says what the reader will learn, and we reuse it in the page metadata.

The order drives the sidebar and the previous and next links. The group splits the sidebar into sections such as “Start here” and “Guides.” The draft flag keeps unfinished pages out of the production build.

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

Markdown files hold the content. content.config.ts validates their frontmatter. The [...slug].astro page turns each file into a URL, and the layout adds the header, sidebar, and styles around it. That is all there is to it.

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.

Do not skip the schema. Without it, a typo like ordr: 3 in one file becomes undefined somewhere in the sidebar code, and you find out when the page looks wrong. With it, the build fails and Astro tells you which file and which field.

This matters more as the site grows. With four pages you would spot the problem. With two hundred, you would not.

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 short on purpose. Real docs pages would be longer, but the shape is the same: a title, a description, and steps the reader can follow.

Notice that every code block has a language tag. Astro highlights code at build time using that tag, so there is no syntax-highlighting library to load 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. render(doc) then gives us two things:

  • Content, the rendered Markdown component
  • headings, a list of headings in the document

Both go to the layout. The route only loads content. The layout decides how it looks. Keeping the two apart makes each file short enough to read in one go.

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, this time to build the navigation.

We sort by order from frontmatter, so the reading order does not depend on filenames. Then we find the current page in that list: the entry before it is the previous link, the entry after it is the next link.

Map.groupBy() splits the entries by group for the sidebar sections.

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 ## headings. Include every level and the table of contents gets as long as the page.

Astro adds an id to every Markdown heading. Create an account becomes create-an-account, so we can link to #create-an-account directly. No JavaScript involved.

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 and the last page has no next link, because orderedDocs[-1] and orderedDocs[orderedDocs.length] are both undefined. Every page in between gets both.

The sidebar and these links read the same order field, so they can never disagree.

Add basic styles

The styles here are minimal: readable text, a visible sidebar, and code blocks that do not break the layout.

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

With four pages the sidebar is enough. With forty, people want to search.

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

This is where the data-pagefind-body attribute on the article pays off. When Pagefind finds it anywhere on the site, it indexes only the marked regions and skips pages that do not have it. Without it, the sidebar text would show up in every single result.

Add data-pagefind-body to the main content on the documentation homepage too if you want that page in search.

Add a sitemap

A sitemap lists every page for search engines, so new docs pages get found without waiting for a crawler to stumble on them.

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()],
})

The integration picks up every route from getStaticPaths() during the build. There is no list of URLs to keep in sync.

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. That is the payoff of reading from one filtered 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.

Seeing it fail once is worth the thirty seconds. Now you know it is actually checking.

Where to go from here

The site is done: validated Markdown, one page per file, a sidebar, previous and next links, a table of contents, search, and a sitemap.

Things you might 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

Add them when a reader asks for them, not because other documentation sites have them.

Whatever you add, keep the core as it is now:

Markdown → collection → static Astro pages

Writers edit Markdown files. Astro validates and renders them. Pagefind indexes the output. Your host serves static files. There is no database, no content API, no search server, and no JavaScript app wrapped around what is, in the end, a folder of text files.

Tagged: Astro · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about astro: