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 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 component
  • headings, 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.

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.

Tagged: Astro · All topics

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

~~~

Related posts about astro: