On this page
A customer emails support: "Your docs say to see the Backups guide, but I can't find it anywhere." You check. The page exists, it's been live for two weeks, and it's excellent. It's just not in the sidebar, because whoever published it didn't know the menu lived in a different file. Meanwhile the sidebar still lists "Sharing (beta)", which was renamed a month ago and now leads to a 404.
If you look after documentation, a help centre or any site with a section menu, you've probably seen some version of this. The fix isn't more care. It's removing the second list.
Why do docs menus drift?
On many sites, the sidebar is a separate list: a config file, a JSON file or a block of HTML in a template. It names each page, its title in the menu and its position. The pages themselves live somewhere else.
That means every change has to happen twice:
- Add a page, and you also have to add it to the menu. Forget, and the page is published but impossible to find by browsing.
- Rename or move a page, and the menu still points at the old address.
- Delete a page, and the menu keeps a link to nothing.
- Change a title, and the menu shows the old one.
- Reorder a section, and you're editing numbers or moving lines in a file far away from the pages you're thinking about.
Two copies of the same truth will drift apart. It's not a discipline problem; it's arithmetic. The more people (and AI assistants) who edit the docs, the faster it happens, because each of them has to know the second file exists.
The fix: let each page describe its own place
The menu should be derived from the pages, not maintained next to them. Each page carries the little bit of information the menu needs: where it sorts, and what short label to show. The menu is then built fresh from whatever pages exist at build time.
With that setup:
- A new page appears in the menu the moment it's published.
- A deleted page disappears from the menu because it no longer exists.
- A renamed page shows its new title, at its new address.
- Ordering is decided in the page you're already editing.
Breadcrumbs ("Home / Docs / Guides / Backups") and "previous / next" links come from the same data, so they can't disagree with the menu either.
How vagejs does it
vagejs is a static site generator: each page is a Markdown file in src/pages/, and the folder structure becomes the site's addresses. Since version 0.3, two optional frontmatter fields (the labelled settings at the top of a page) handle navigation:
---
title: Installing the app on your computer
description: How to install the app on Windows, macOS and Linux.
order: 1
navTitle: Install
---ordersets the position in a menu, lowest first. Pages without one sort after those with one, then by title.navTitleis a shorter label for menus and breadcrumbs. Without it, the page title is used.
Layouts (the files that wrap each page in its header, sidebar and footer) receive a list of every published page, and vagejs provides helpers that turn it into navigation:
| Helper | What it gives you |
|---|---|
tree(pages, "/docs/") |
The section as a nested tree (folders become levels), each level sorted by order, then title |
breadcrumbs(pages, route) |
The trail from the home page down to the current page |
neighbors(list, route) |
The previous and next page in a list |
flatten(tree) |
The tree in reading order, for previous / next across nested folders |
collection(pages, { under, sort: "order" }) |
A flat, sorted list for simpler sections |
A real sidebar, from start to finish
We tried this on a fresh starter site with vagejs 0.3.0 on 24 October 2026. We added a small docs section: an index page, Install (order: 1), Getting started (order: 2), a Guides folder (order: 3) holding Sharing (order: 1) and Backups (order: 2), and an FAQ with no order at all. Then we wrote a short docs layout:
// src/layouts/docs.ts
import { breadcrumbs, flatten, html, neighbors, raw, tree, type Layout, type TreeNode } from "vagejs";
import defaultLayout from "./default.ts";
const menu = (nodes: TreeNode[]): unknown =>
html`<ul>${nodes.map((n) => html`<li><a href="${n.page.route}">${n.page.label}</a>${n.children.length ? menu(n.children) : ""}</li>`)}</ul>`;
const layout: Layout = (props) => {
const { pages, page } = props;
const nodes = tree(pages, "/docs/");
const trail = breadcrumbs(pages, page.route);
const { prev, next } = neighbors(flatten(nodes), page.route);
const body = html`<nav aria-label="Breadcrumb">${trail.map((p, i) => html`${i ? " / " : ""}<a href="${p.route}">${p.label}</a>`)}</nav>
<nav aria-label="Docs">${menu(nodes)}</nav>
${raw(props.content)}
<nav aria-label="Pager">${prev ? html`<a href="${prev.route}">← ${prev.label}</a>` : ""} ${next ? html`<a href="${next.route}">${next.label} →</a>` : ""}</nav>`;
return defaultLayout({ ...props, content: String(body) });
};
export default layout;Each docs page sets layout: docs. After npm run build, here's the navigation vagejs wrote into the Backups page (we added line breaks and left out the page text in the middle):
<nav aria-label="Breadcrumb"><a href="/">Home</a> / <a href="/docs/">Docs</a> /
<a href="/docs/guides/">Guides</a> / <a href="/docs/guides/backups/">Backups</a></nav>
<nav aria-label="Docs"><ul><li><a href="/docs/installation/">Install</a></li>
<li><a href="/docs/getting-started/">Getting started</a></li>
<li><a href="/docs/guides/">Guides</a><ul><li><a href="/docs/guides/sharing/">Sharing</a></li>
<li><a href="/docs/guides/backups/">Backups</a></li></ul></li>
<li><a href="/docs/faq/">FAQ</a></li></ul></nav>
...
<nav aria-label="Pager"><a href="/docs/guides/sharing/">← Sharing</a> <a href="/docs/faq/">FAQ →</a></nav>Notice what we never wrote: a list of pages. Install comes first because of order: 1. Sharing sits above Backups inside Guides because of its own order. The FAQ, with no order, lands at the end. The menu says "Install" rather than "Installing the app on your computer" because of navTitle. And the "next" link from Backups jumps out of the Guides folder to the FAQ, because flatten() follows the same reading order as the sidebar.
Write the layout once. After that, adding a page to the docs means adding a file.
Mistakes are caught before they're published
Because navigation comes from frontmatter, vagejs checks those fields. We gave a page order: two by mistake, and npx vage check replied:
error page-error src/pages/docs/upgrading.md
Invalid frontmatter:
✖ Invalid input: expected number, received string
→ at orderOther mistakes are caught too. A link in the body of a page to an address that doesn't exist fails vage check, and so does a link to a draft page. Drafts (draft: true) are left out of the page list entirely, so a half-written page never shows up in the menu while it's still visible to you in npm run dev.
Renaming a page without breaking old links
When you rename a docs page, the menu updates itself. Links from elsewhere on the web won't, so add a redirect in vage.config.ts:
redirects: { "/docs/guides/sharing-beta/": "/docs/guides/sharing/" },vage check reports a redirect whose target doesn't exist. The routing docs cover redirects, and templates and layouts cover the helpers in more detail.
We use it ourselves
The documentation sidebar on this website is built the same way. Each docs page sets order and navTitle, and the layout lists the section with collection(pages, { under: "/docs/", includeIndex: true, sort: "order" }). When we add a docs page, we don't touch the sidebar.
Limits of a menu built from pages
- You write the docs layout. New vagejs sites come with a default layout and a blog layout, not a docs layout. The example above is about twenty lines, and an AI coding assistant can write it from the instructions in
AGENTS.md, but it's still code someone has to own. - Order numbers are still manual. If you want a new page third, you give it an
orderthat puts it third, and sometimes that means renumbering a few neighbours. Leaving gaps (10, 20, 30) helps. - Folders need an index page to become a level. In
tree(), a folder without its ownindex.mddoesn't get a heading; its pages move up a level. That's by design, but it can surprise you. - No search. vagejs builds static pages without JavaScript by default, and there's no built-in docs search.
- Links to other websites aren't pages. If your menu should include an external link, such as a status page, you add it to the layout by hand, and its domain needs approving in
links.lock.json. - Content can still go stale. A menu built from pages is always accurate about which pages exist. Whether what they say is still true is up to you.
Build a docs menu that updates itself
You need Node.js 22.12 or newer:
npm create vagejs@latest my-docs
cd my-docs
npm install
npm run devMake a src/pages/docs/ folder with a few pages, give them order and navTitle, and ask your AI assistant: "Add a docs layout with a sidebar from tree(pages, "/docs/"), breadcrumbs and previous/next links, as described in AGENTS.md." Then run npm run check. The getting started guide and our prompt library will help with the rest.
Self-updating docs menus at a glance
- Docs menus drift because they're a second copy of information that already lives in the pages.
- Build the menu from the pages instead: each page states its
orderand an optional shortnavTitle. - In vagejs,
tree(),breadcrumbs(),neighbors()andflatten()turn that into a sidebar, a trail and previous/next links at every build. - Invalid fields fail
vage check, drafts stay out of menus, and redirects keep renamed pages reachable. - You still write the layout once and pick the numbers. After that, a new page is just a new file.