Skip to content

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

  1. Scan -- globSync finds all markdown files, excluding node_modules, .vitepress, the root index.md, and any custom patterns.
  2. Extract titles -- Each file's frontmatter title is used if present; otherwise the filename is converted to title case as a fallback.
  3. Group -- Pages are grouped by their top-level directory. A file at guide/getting-started.md belongs to the "Guide" section.
  4. Build nav -- Each section becomes a nav entry. Single top-level files link directly; sections with multiple pages link to the first page alphabetically.
  5. Build sidebar -- Each multi-page section gets a sidebar group. Pages nested in subdirectories become collapsible sub-sections, all sorted alphabetically.
  6. 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 exclude option
  • [ ] Frontmatter title is the authoritative source for display names
  • [ ] Build completes without errors (npm run docs:build)