Sidebar Navigation in Astro and Eleventy
The sidebar is the most-used component on a documentation site and the one most often built badly. It is rendered on every page, so its HTML weight multiplies across the site; it depends on every page's title and order, so it can defeat incremental builds; and it is the main way readers orient themselves, so ordering, nesting and the active state matter more than any visual detail. Astro and Eleventy both leave the sidebar to you — unlike docs frameworks such as Starlight, covered in Docs Frameworks: Docusaurus, Starlight and VitePress — and they encourage slightly different designs.
This guide builds the same nested, ordered, collapsible sidebar for a 1,500-page documentation site in both generators, then measures its effect on page weight and incremental build time. It is part of Astro vs Eleventy for Documentation Sites.
Prerequisites
- Docs content organised in folders that reflect sections (
guides/deploy/…,reference/cli/…). - Front matter fields for navigation:
title, optionalnavTitle(a shorter label) andorder. - A decision on how deep the sidebar goes — two or three levels is typical.
The Data Model
Both implementations use the same model: folder structure defines sections, front matter defines labels and order, and a small config file names top-level sections and their order:
# src/data/sections.yml
- { dir: start, label: Get started }
- { dir: guides, label: Guides }
- { dir: reference, label: Reference, collapsed: true }
- { dir: changelog, label: Changelog, collapsed: true }
# a page's front matter
title: Deploying Hugo to Cloudflare Pages and Workers
navTitle: Cloudflare Pages
order: 20
Ordering by explicit order values with gaps (10, 20, 30) lets writers insert a page between two others without renumbering. Pages without order sort alphabetically after ordered ones.
Astro: A Collection Query and a Recursive Component
In Astro, a content collection provides every page with its front matter at build time. A helper builds the tree once per build and a recursive component renders it:
// src/lib/nav.ts
import { getCollection } from 'astro:content';
import sections from '../data/sections.yml';
let cached: NavNode[] | undefined;
export async function getNav() {
if (cached) return cached; // built once, reused by every page
const docs = await getCollection('docs', (d) => !d.data.draft);
cached = sections.map((s) => ({
label: s.label, collapsed: s.collapsed ?? false,
children: tree(docs.filter((d) => d.id.startsWith(`${s.dir}/`)), s.dir),
}));
return cached;
}
---
// src/components/NavTree.astro
const { nodes, current } = Astro.props;
---
<ul>
{nodes.map((n) => n.children?.length ? (
<li><details open={!n.collapsed || current.startsWith(n.href ?? '~')}>
<summary>{n.label}</summary>
<Astro.self nodes={n.children} current={current} />
</details></li>
) : (
<li><a href={n.href} aria-current={n.href === current ? 'page' : undefined}>{n.label}</a></li>
))}
</ul>
Module-level caching of the tree matters at scale: without it, every one of 1,500 pages re-queried and re-sorted the collection, which added 9 seconds to the build.
Eleventy: A Computed Collection and a Nunjucks Macro
Eleventy offers two routes. The quick route builds the tree from collections.all in a layout, but that makes every page depend on every page's data, which defeats incremental builds. The better route is a custom collection computed once, plus a data file for structure:
// eleventy.config.js
eleventyConfig.addCollection('navTree', (api) => {
const docs = api.getFilteredByGlob('src/docs/**/*.md').filter((p) => !p.data.draft);
return sections.map((s) => ({ ...s, children: tree(docs.filter((p) => p.filePathStem.startsWith(`/docs/${s.dir}/`))) }));
});
{% macro navTree(nodes, current) %}
<ul>{% for n in nodes %}
{% if n.children and n.children.length %}
<li><details {% if not n.collapsed or current.startsWith(n.url) %}open{% endif %}>
<summary>{{ n.label }}</summary>{{ navTree(n.children, current) }}</details></li>
{% else %}
<li><a href="{{ n.url }}" {% if n.url == current %}aria-current="page"{% endif %}>{{ n.label }}</a></li>
{% endif %}
{% endfor %}</ul>
{% endmacro %}
Page Weight: Render Less of the Tree
A full sidebar for 1,500 pages is a lot of HTML on every page. Measured on the docs site:
| Sidebar strategy | Sidebar HTML per page (raw) | Transfer (brotli) | Total HTML per page |
|---|---|---|---|
| Full tree, all sections expanded | 184 KB | 17 KB | 212 KB |
Full tree in <details>, current section open | 184 KB | 17 KB | 212 KB |
| Current section full, others as top-level links only | 22 KB | 3.1 KB | 50 KB |
| Current section full, others loaded on expand | 22 KB + 4 KB on demand | 3.1 KB | 50 KB |
Collapsing with <details> improves readability but not weight — closed sections are still in the HTML. Rendering only the current section's tree, with other sections as single links to their index pages, cut HTML per page by 76% and raised Lighthouse's DOM-size score from "avoid an excessive DOM size" (3,900 elements) to passing (900). For readers who want the full tree, a site map page or a section index serves it.
Keeping Incremental Builds Incremental
In Eleventy, the sidebar decides whether incremental builds work. When the navigation tree is derived from every page's title, editing any title — or just saving a page, since Eleventy cannot know which fields changed — marks every page that renders the sidebar as dirty. On this site that turned a 2-second incremental rebuild into a 31-second full one.
The fix is to separate structure from content. Generate nav.json from front matter in a small pre-build script that only rewrites the file when the tree actually changes (compare a hash), and render the sidebar from that data file. Editing a page's body no longer touches nav.json, so only that page rebuilds; renaming or reordering a page does, correctly, rebuild everything. With this change, 92% of edits over a month rebuilt incrementally in under 3 seconds. Astro rebuilds every page regardless, so the same separation does not speed it up, but caching the tree still saves the per-page cost described above. The Eleventy side is covered further in Enabling Incremental Builds in Eleventy.
The pre-build script runs in about 400 ms on 1,500 pages because it only reads front matter, not page bodies, using gray-matter on each file. Keep its output sorted and stable, or unrelated key-order changes will change the hash and trigger full rebuilds for no reason.
Accessibility
A sidebar is a navigation landmark, and a few details make it usable with assistive technology: wrap it in <nav aria-label="Documentation">; mark the current page with aria-current="page" rather than only a colour; use <details>/<summary> so sections expand with the keyboard and announce their state without JavaScript; and keep link text meaningful out of context — navTitle exists so labels can be short without becoming ambiguous. On mobile, move the sidebar behind a disclosure button at the top of the page rather than an off-canvas drawer that traps focus.
Pitfalls & Rollback
- Rebuilding the tree per page. Cache it once per build; it saved 9 seconds on 1,500 pages.
- Full trees on every page. HTML weight and DOM size grow with the site. Render the current section.
- Sidebar from
collections.allin Eleventy. It makes every page depend on every other; use a structure-only data file. - Colour-only active state. Use
aria-current="page"so screen readers announce it. - Unstable ordering. Pages without an
ordervalue sorted by filesystem order produce different sidebars on different machines. Always fall back to a deterministic sort such as title. - Draft pages in navigation. Filter drafts in the tree builder, not in the template, so no page links to something that was not built.
- Rollback: the sidebar is one component and one helper in each generator; reverting restores the previous navigation without touching content.
Conclusion
A good docs sidebar is built once per build from three inputs — a section list, the folder structure and front matter — and rendered on each page with only the current section expanded and the active page marked. On a 1,500-page site that cut HTML per page by three quarters, passed Lighthouse's DOM-size audit, and, in Eleventy, kept 92% of edits on the fast incremental path by separating navigation structure from page content.
FAQ
Should a docs sidebar be generated from files or written by hand?
Generate the structure from files and front matter so new pages appear automatically, and keep ordering and section labels in front matter or a small config file. Fully hand-written sidebars drift from the content; fully automatic ones produce awkward alphabetical orders.
How big can a sidebar get before it hurts performance?
When every page renders the full sidebar, HTML size grows with the number of pages. A 1,500-page docs site with a full nested sidebar added about 180 KB of uncompressed HTML to every page. Render only the current section expanded and collapse the rest, or load deep levels on demand.
Why does editing one page rebuild the whole Eleventy site?
If the sidebar is built from a collection that includes page titles, every page depends on every other page's title, so Eleventy's incremental mode must rebuild everything. Building navigation from a data file that changes only when structure changes avoids that.
Does the sidebar need JavaScript?
No. Collapsible sections work with details and summary elements, and the active page can be marked at build time. JavaScript is only useful for remembering which sections a reader opened, and that can be a small progressive enhancement.
Related
- Parent: Astro vs Eleventy for Documentation Sites — the wider comparison.
- Astro vs Eleventy Build Times at 10,000 Pages — where the incremental caveat was measured.
- Content Collections vs Eleventy Data Cascade — the data layers the sidebar reads.
- Customizing Starlight Without Forking the Theme — when a framework's sidebar is enough.
- Fixing LCP on Text-Heavy Documentation Pages — HTML weight and first paint.