Version your documentation without duplicating it. Author only the diff between versions β the engine resolves the rest.
Documentation Β· Authoring Β· Why not duplicate? Β· npm
Every documentation framework versions by snapshot: cutting a release copies the whole tree. From then on a typo present in four versions takes four edits or survives in three of them, a reviewer cannot see what actually changed between two releases, and the repository grows by one full tree per release.
docs-overlay inverts that. The oldest version folder holds the complete tree; every newer folder holds
only what it changed β an override, a new page, a rename, a tombstone. Everything else is inherited,
resolved at build time.
Snapshot versioning docs-overlay
docs/ content/docs/
βββ v1/ βββ v1/ the complete tree
β βββ intro.md β βββ intro.md
β βββ guide.md β βββ guide.md
β βββ api.md β βββ api.md
βββ v2/ βββ v2/ only what changed
β βββ intro.md β identical copy β βββ guide.md
β βββ guide.md β
β βββ api.md β identical copy β
βββ v3/ βββ v3/ only what changed
βββ intro.md β identical copy βββ api.md
βββ guide.md β identical copy
βββ api.md
v2 serves intro and api by inheriting them from v1. v3 serves intro from v1 and guide
from v2. Nothing is copied, so nothing can drift apart.
Resolution is a walk up the chain, and it stops at the nearest version that owns the file:
resolve("v3", "intro")
v3/intro.md not here
v2/intro.md not here
v1/intro.md served β and the page can say "Unchanged since v1"
Cutting the next release is one command, and it moves a folder rather than copying a tree:
docs-overlay cut 2.0.0On the real 216-page site this was built for, migrating two releases onto an overlay took 575 tracked files down to 351 β 39% fewer β and the cut itself was 216 renames that git recorded at R100, with zero insertions and zero deletions. The emptied channel folder inherits everything again.
- One copy of each page. A fix lands once, in the version that owns the file, and every version inheriting it gets the fix.
- The diff is the release note.
git diffbetween two version folders is exactly what changed for readers, with nothing to read past. - Cutting a release is cheap. A folder rename. No snapshot to review, no
versions.jsonto bump. - Removals explain themselves. A tombstone can carry
replacedBy, so an old URL gets an explanation instead of a 404, and a renamed page keeps a permanent redirect. - Nothing to keep in sync. The list of versions is the list of folders.
- The core is framework-agnostic. Zero dependencies, no Node built-ins, ESM. Frameworks are adapters on top of it.
docs-overlay
framework-agnostic core
zero dependencies
β
ββββββββββββββββββββββββΌβββββββββββββββββββββββ
β β β
docs-overlay- docs-overlay- your adapter
fumadocs docusaurus β
β β β
Fumadocs Docusaurus Astro, VitePress, a scriptβ¦
| Your setup | Install | How it works |
|---|---|---|
| Fumadocs / Next.js | docs-overlay + docs-overlay-fumadocs |
Re-projects the source Fumadocs already read. One loader() serves every version. Nothing is written to disk. |
| Docusaurus | docs-overlay + docs-overlay-docusaurus + docs-overlay-cli |
The adapter plans the snapshot tree Docusaurus insists on reading; the CLI writes it as a prebuild step. URLs come out identical to a plain Docusaurus site. |
| Anything else | docs-overlay |
The engine answers (version, slug) questions and knows nothing about frameworks. Writing an adapter never requires a change to the core. |
| Just the plumbing | docs-overlay-cli |
cut, check and prune work on any repository that follows the folder convention. |
Not supported in
0.x: i18n on top of versions. Fumadocs'i18n.parser: "dir"claims the same first path segment as the version, and Docusaurus keys its translations by version. Neither combination is folded yet.
npm install docs-overlay docs-overlay-fumadocsImportant
pageSchema is a zod object in strip mode, so an overlay: key in frontmatter is silently
dropped before it reaches page.data. Widen the schema with withOverlay(). Skip this and everything
appears to work β except that no directive has any effect, with no error to explain why.
// source.config.ts
import { pageSchema } from "fumadocs-core/source/schema";
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
import { withOverlay } from "docs-overlay-fumadocs/schema";
export const docs = defineDocs({
dir: "content/docs",
docs: { schema: withOverlay(pageSchema) }
});
export default defineConfig({});// lib/source.ts
import { loader } from "fumadocs-core/source";
import { docs } from "collections/server";
import { overlaySource } from "docs-overlay-fumadocs";
export const overlay = overlaySource({
source: docs.toFumadocsSource(),
baseUrl: "/docs",
channels: ["next"],
// `/docs/...` is the newest release, `/docs/1.0.0/...` an older one.
latestAtRoot: true,
labels: { next: "Next π§" }
});
export const source = loader({ baseUrl: "/docs", source: overlay.source, url: overlay.url });The catch-all route β resolveRoute() and its four outcomes, and why generateStaticParams() must use
staticParams(overlay) β is in the adapter's readme. The complete
file that compiles is examples/fumadocs-next/app/docs/[[...slug]]/page.tsx.
npm install -D docs-overlay docs-overlay-cli docs-overlay-docusaurus{
"scripts": {
"materialize": "docs-overlay materialize",
"verify": "docs-overlay materialize --check",
"prebuild": "npm run materialize",
"prestart": "npm run materialize"
}
}You edit content/docs/; docs/, versioned_docs/, versioned_sidebars/ and versions.json become
build output. Put docs-overlay materialize --check in CI β it turns an edit made in the generated tree
into a failed build instead of an edit that disappears without a trace. The full walkthrough is
Versioning Docusaurus documentation without snapshots,
and examples/docusaurus-classic is a working site you can clone and run.
npm install docs-overlayimport { createOverlay } from "docs-overlay";
const overlay = createOverlay({ source: entries, channels: ["next"] });
const outcome = overlay.resolve("2.0.0", ["guide", "intro"]);
// outcome.kind is exactly one of:
// own Β· inherited Β· alias Β· redirect Β· deleted Β· missing Β· unknown-version
// Each branch carries only its own fields, so a switch over it is checked by the compiler. For an
// inherited page, `outcome.page.source.definedIn` names the version that actually wrote the file.See Writing an adapter.
Versions are top-level folders under the content root, ordered by semver, with declared non-semver
folders β channels such as next β sorted last. There is no versions.json to maintain.
content/docs/
1.0.0/ the complete tree, frozen for good
guide/intro.md
guide/old-api.md
api/index.md overlay: { aliases: api-reference }
3.0.0/ differences only
guide/intro.md an override β no directive; the file itself is the diff
guide/new-api.md overlay: { renamedFrom: guide/old-api }
guide/legacy.md overlay: { deleted: true, replacedBy: guide/new-api }
next/ work in progress β empty here, so it inherits everything
Every directive lives in the YAML frontmatter under a single overlay: key, never in a filename, and
always in the version that introduces the change:
| Operation | How | What readers get |
|---|---|---|
| add / change a page | the file itself, no directive | the new content, from that version on |
| rename a page | overlay: { renamedFrom: guide/old-api } on the new file |
a permanent redirect from the old slug, inherited forward |
| delete a page | a file at the deleted slug, in the version that removes it: overlay: { deleted: true, replacedBy: guide/modern } |
an explanation instead of a 404. Add recursive: true for the whole subtree |
| re-add a page | put the file back; there is no special case | the page again |
| alias a page | overlay: { aliases: api-reference } β a string or a list |
a second URL with a canonical. An alias never shadows a page |
Priority inside a version is fixed, so "I renamed onto a slug that already exists" has one answer:
own file > tombstone > rename/redirect > alias > inherited
1.0.0 ββββββββββββ the complete tree
β
βββ 2.0.0 ββββ changes only
β
βββ 3.0.0 ββββ changes only
Because a directive lives in the version that introduces it, a published folder is finished. The
deletion version is derived from the tombstone's own path, so there is no version string to write and
nothing that can drift; cutting 3.0.0 cannot modify 1.0.0; and the release is auditable with one
command:
grep -rl 'deleted: true' content/docs/2.0.0/ # exactly what disappears in that releaseThe cost on the other side is worth naming: editing a file in an old version changes what every version
inheriting it serves. docs-overlay check lists what a version serves by inheritance, which is worth a
CI job of its own.
docs-overlay cut <version> the channel folder becomes that version (git mv, so the diff is renames)
docs-overlay check the engine's diagnostics, with no framework build
docs-overlay prune drop files a version repeats byte for byte from what it inherits
docs-overlay materialize write the tree Docusaurus reads (needs docs-overlay-docusaurus)
cut and prune take --dry-run. check and prune take --json, for CI. materialize --check
writes nothing and exits non-zero when the generated tree is stale. materialize loads the Docusaurus
adapter through a lazy import(), so a Fumadocs project that installs the CLI to move a folder never
pulls Docusaurus knowledge in.
The first invocation is
npx docs-overlay-cli, notnpx docs-overlay: the latter resolves the engine package, which has no bin. Afterwards thedocs-overlaybin works from package scripts.
Every flag is in the CLI readme.
docs-overlay-mermaid has nothing to do with versioning. It turns
a Mermaid source into a modern technical SVG at build time, without a browser β the gap the
existing options leave, which is either shipping the Mermaid bundle to the reader or driving a headless
Chromium through rehype-mermaid.
npm install docs-overlay-mermaidimport { renderMermaid } from "docs-overlay-mermaid";
const { svg } = await renderMermaid(`
flowchart LR
Developer --> Angular
Angular --> API
API --> PostgreSQL
API --> Redis
`);PostgreSQL comes out drawn as a database and Redis as a cache, with nothing annotated: between the
parser and the renderer sits a semantic model, and that is what a theme draws. No JavaScript reaches
the reader, no request is made during the build, and the same input always produces the same bytes.
It does not depend on docs-overlay and knows nothing about versioned documentation β it lives
here to share the build, the release and the test suite. Its
readme states the Mermaid subset it covers, which is deliberately
smaller than all of Mermaid.
A library with a supported-version policy. Readers need the documentation that matches the version
they installed, so 1.x, 2.x and 3.x all have to stay online. Three lines may be all that changed
between two of them β and with snapshots, saying so costs three full trees that then drift apart.
A monorepo publishing several packages. Each product releases on its own schedule, so one version
list cannot describe them all. The Fumadocs adapter's scope option gives each documentation its own
versions inside one site, one page tree and one search index β see
Several documentations and
examples/fumadocs-multi.
A design system. A component page changes once per breaking release and is stable in between. Duplicating it every release is what lets the copies diverge.
An SDK across languages or platforms. The conceptual pages are shared; only the reference pages fork per version.
Snapshots are not wrong, they are just expensive in ways that only show up later:
| With a full copy per version | What it costs |
|---|---|
| fix a typo present in four versions | four edits, or the typo survives in three of them |
| review a release | a diff of ~200 files in which ~190 are identical |
| answer "what changed for readers in 11.14.0?" | nobody can, from git alone |
| mark new and updated pages in the sidebar | hand-maintained, so it decays. Measured on one real 216-page version: 8 badges outright wrong, 65 missing, 96 right β and the base version, which has no predecessor to be new against, claimed 6 pages were new |
| keep an old URL alive after a rename | a redirect plugin, configured by hand, per version |
And what it costs you instead, because this is not free either:
- Resolution is a concept your contributors have to learn.
- Inheritance is invisible to readers unless the site says so β the adapters expose
inheritedFromfor exactly that, but you have to render it. - On Docusaurus,
docs/becomes a build artefact, which is a change of reflex for every contributor. - Deduplication itself is a modest win. On the measured corpus 119 of ~160 shared files genuinely differed between two adjacent versions; the payoff is the cut, the reviewability and the absence of drift, not the file count.
- 628 tests across 42 files (Vitest). The core's fixtures are TypeScript factories, never files on disk β it is filesystem-free and its tests stay that way. The CLI, which is the only package that writes anything, is tested against a real tree on a real disk.
- Three end-to-end suites assert the exported HTML of three built sites, one per framework path:
examples/fumadocs-nextcovers multi-hop inheritance, a tombstone withreplacedBy, a re-add, a rename redirect that still works in the newest release, navigation inheritance, an alias with its canonical, and the "Unchanged since" notice β including its absence on a version that owns the page.examples/fumadocs-multicovers several documentations side by side.examples/docusaurus-classicis a real Docusaurus build withonBrokenLinks: "throw", which is the only thing that proves Docusaurus accepts the tree the adapter plans β including that every generated sidebar is valid for its own version. - One integration test runs the real
loader()fromfumadocs-core, because the contract that matters is the one Fumadocs implements. - The core's independence is proven three ways: a static architecture test that forbids
react,next,fumadocs-*,astro,nextra,vitepressand everynode:*import and requiresdependenciesandpeerDependenciesto be empty; the same treatment for the Docusaurus adapter's "performs no I/O"; andnpm run verify:independence, which packs the core withnpm packand runs a probe against it in a temporary directory with nonode_modulesat all. docs-overlay-mermaidproves it needs no browser the same way: a static test forbids every framework andnode:*import, pins its two dependencies exactly, and refuses any occurrence ofwindow,document,HTMLElement,customElements,navigatororlocalStoragein the shipped sources β a DOM dependency there would mean the package no longer renders at build time, which is the only reason it exists. Its layout is asserted deterministic, because every snapshot depends on it.npm run typecheck:packagedtypechecks the adapters against the built.d.tswith no source alias in play, which is what validates the publishedexportsmaps.- A performance guard: 10 versions Γ 500 pages fold in under a second, and 10 000
resolve()calls trigger no additional fold.
The site at hebus.github.io/docs-overlay is served by
docs-overlay, so it is its own proof.
Its pages live in apps/docs/content/docs/: the oldest folder holds the
complete tree, the newer one holds only the pages an actual release rewrote, next/ holds only what an
unreleased change touched β often nothing β and every inherited page says which version wrote it.
scripts/cut-docs.mjs performs the cut inside the "chore: version packages" pull request, so it is
reviewed alongside the version bump rather than remembered afterwards.
| Page | About |
|---|---|
| Overview | What it is, what to install |
| Authoring | Folders, the operations, releases, maintenance branches |
| Resolution | The fold, the priority order, the truth table |
| Architecture | The core/adapter boundary, and how it is kept honest |
| Writing an adapter | What the engine gives you, and what breaks a site quietly |
| Staying on Docusaurus | Keep Docusaurus, drop the snapshots |
| Migrating to Fumadocs | Two frameworks, one content model, and the honest payoff |
| Several documentations | One site, one product per scope, each with its own versions |
The one rule is that adapters depend on the core and never the reverse β the core must never import a framework or a Node built-in, and two guards fail the build if that slips. Setup, the checks to run before committing, changesets and the release process are in CONTRIBUTING.md.