Appearance
Auto Nav
Auto-generate VitePress navigation and sidebar config from the file system.
When to use
Use this pattern when maintaining nav and sidebar config manually becomes tedious or error-prone. The folder structure becomes the single source of truth: add or move a markdown file and the navigation updates automatically at build time.
The pattern
A build-time script scans markdown files, extracts titles from frontmatter (falling back to formatted filenames), groups pages by top-level directory, and produces both nav and sidebar config objects.
Set titles in frontmatter
Every markdown file should specify its display title in frontmatter. This is how the navigation gets correct names for abbreviations, proper nouns, and anything that simple title-casing would get wrong:
md
---
title: "CSS Custom Properties"
---If a file has no title in its frontmatter, the filename is title-cased as a fallback (e.g., getting-started.md becomes "Getting Started"). This works for simple names but will produce wrong results for abbreviations like CSS or API, so always set the title explicitly.
Generate navigation config
Create .vitepress/nav.js:
js
import { globSync } from "glob";
import matter from "gray-matter";
import { readFileSync } from "fs";
import { basename } from "path";
const EXCLUDE = [
"node_modules/**",
".vitepress/**",
"index.md",
];
export function generateNav({ exclude = [] } = {}) {
const allExclude = [...EXCLUDE, ...exclude];
const files = globSync("**/*.md", { ignore: allExclude });
const pages = files.map((f) => ({
segments: f.replace(/\.md$/, "").split("/"),
title: extractTitle(f),
link: "/" + f.replace(/\.md$/, "").replace(/\/index$/, "/"),
}));
const sections = groupByTopLevel(pages);
return {
nav: buildNav(sections),
sidebar: buildSidebar(sections),
};
}
function extractTitle(filePath) {
try {
const { data } = matter(readFileSync(filePath, "utf-8"));
return data.title || formatFilename(filePath);
} catch {
return formatFilename(filePath);
}
}
function formatFilename(filePath) {
return basename(filePath, ".md")
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
function groupByTopLevel(pages) {
const groups = new Map();
for (const page of pages) {
const key = page.segments[0];
if (!groups.has(key)) {
groups.set(key, {
name: formatSegment(key),
pages: [],
});
}
groups.get(key).pages.push(page);
}
// Sort groups alphabetically by name
return [...groups.entries()]
.sort(([, a], [, b]) => a.name.localeCompare(b.name))
.map(([key, group]) => ({ key, ...group }));
}
function formatSegment(segment) {
return segment
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
function buildNav(sections) {
return sections.map((section) => {
// Single-page section (top-level file, not a directory)
if (section.pages.length === 1 && section.pages[0].segments.length === 1) {
const page = section.pages[0];
return { text: page.title, link: page.link };
}
// Multi-page section — link to the first page alphabetically
const sorted = [...section.pages].sort((a, b) =>
a.title.localeCompare(b.title),
);
return {
text: section.name,
link: sorted[0].link,
};
});
}
function buildSidebar(sections) {
const sidebar = {};
for (const section of sections) {
// Skip single top-level files (no sidebar needed)
if (section.pages.length === 1 && section.pages[0].segments.length === 1) {
continue;
}
const key = `/${section.key}/`;
sidebar[key] = buildSidebarGroup(section.pages, section.name);
}
return sidebar;
}
function buildSidebarGroup(pages, sectionName) {
// Group pages by subdirectory
const subgroups = new Map();
const topLevel = [];
for (const page of pages) {
if (page.segments.length <= 2) {
// Direct child of this section
topLevel.push({ text: page.title, link: page.link });
} else {
// Nested in a subdirectory
const subdir = page.segments[1];
if (!subgroups.has(subdir)) {
subgroups.set(subdir, []);
}
subgroups.get(subdir).push({ text: page.title, link: page.link });
}
}
// Sort top-level pages alphabetically
topLevel.sort((a, b) => a.text.localeCompare(b.text));
// Build sidebar items: top-level pages + collapsible subgroups
const items = [...topLevel];
const sortedSubgroups = [...subgroups.entries()].sort(([a], [b]) =>
a.localeCompare(b),
);
for (const [subdir, subPages] of sortedSubgroups) {
subPages.sort((a, b) => a.text.localeCompare(b.text));
items.push({
text: formatSegment(subdir),
collapsed: true,
items: subPages,
});
}
return [{ text: sectionName, items }];
}Wire it into VitePress config
In .vitepress/config.js, call generateNav and spread the results:
js
import { defineConfig } from "vitepress";
import { generateNav } from "./nav.js";
const { nav, sidebar } = generateNav({
// Additional patterns to exclude beyond the defaults
exclude: ["drafts/**", "archive/**"],
});
export default defineConfig({
title: "My Docs",
themeConfig: { nav, sidebar },
});How it works
- Scan --
globSyncfinds all markdown files, excludingnode_modules,.vitepress, the rootindex.md, and any custom patterns. - Extract titles -- Each file's frontmatter
titleis used if present; otherwise the filename is converted to title case as a fallback. - Group -- Pages are grouped by their top-level directory. A file at
guide/getting-started.mdbelongs to the "Guide" section. - Build nav -- Each section becomes a nav entry. Single top-level files link directly; sections with multiple pages link to the first page alphabetically.
- Build sidebar -- Each multi-page section gets a sidebar group. Pages nested in subdirectories become collapsible sub-sections, all sorted alphabetically.
- Sort -- Sections, pages, and subgroups are sorted alphabetically so ordering is predictable regardless of file-system order.
Checks
- [ ] File system is the single source of truth -- no manual nav/sidebar config
- [ ] Titles extracted from frontmatter with filename fallback
- [ ] Alphabetical sort applied to sections, pages, and subgroups
- [ ] Nested folders produce collapsible sidebar sections
- [ ] Exclude patterns are configurable via the
excludeoption - [ ] Frontmatter
titleis the authoritative source for display names - [ ] Build completes without errors (
npm run docs:build)