Snapshot versioning
cp -r docs/ versioned_docs/version-11.15.0/
- ~190 files copied, a ~1.2 MB commit
- The sidebar duplicated alongside them
- A typo now lives in every copy
- Deleting a page means editing frozen folders by hand
docs-overlay 0.2.1fumadocs 0.2.3docusaurus 0.2.2cli 0.3.0
Versioned documentation where each version folder holds only what actually changed — an override, a new page, a rename, or a tombstone. The oldest folder is the complete tree; everything after it is an overlay. Two adapters — Fumadocs and Docusaurus — on an engine that depends on neither.
npm install docs-overlay docs-overlay-fumadocsCutting a release
Snapshot versioning copies the whole tree every time. An overlay records only the change, so cutting a version costs nothing and duplicates nothing.
Snapshot versioning
cp -r docs/ versioned_docs/version-11.15.0/
Overlay versioning
git mv content/docs/next content/docs/11.15.0 mkdir content/docs/next
~190 files0 bytes
The adapters
The engine knows nothing about either framework, and the two integrations are genuinely different shapes. docs-overlay-fumadocs re-projects the source Fumadocs already read, and writes nothing: three files, and the third one you already have.
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({});withOverlay() is not optional
pageSchema is a zod object in strip mode, so an overlay: key in frontmatter is dropped before it reaches your page data. Skip withOverlay() and everything appears to work — the site builds, pages render, search runs — except that no directive has any effect, with no error to explain why.One loader() serves every version. One page tree, one search index — and a relative link such as ./b.md resolves inside the version it was written in.
overlaySource()
Wraps the source Fumadocs already built. A single loader() then serves every version — one page tree, one search index.
resolveRoute()
Turns the catch-all params into a decision: page, redirect, gone, or not-found — with inheritedFrom naming the version that wrote the page.
staticParams()
Generates every routable URL, aliases and removed pages included. source.generateParams() knows only pages, and on a static host a missing route is a 404, not a redirect.
versionTabs() · switchVersion()
A version picker that falls back cleanly when the page does not exist in the version being switched to.
searchTagsOf()
Scopes the search index per version. Without it, a page served by five versions returns five identical results.
findOrphanPages() · diagnostics
An unreachable page or a broken directive fails the build instead of shipping quietly to the reader.
Docusaurus reads its versions from fixed paths on disk, inside the docs plugin’s own factory, before any hook could intervene — so there is no moment at which an overlay could resolve inheritance on the fly. docs-overlay-docusaurus plans the tree it expects, and docs-overlay-cli writes it as a prebuild step. The URLs come out identical, so nothing linked from outside your site moves.
npm install -D docs-overlay docs-overlay-cli docs-overlay-docusaurusSource — committed, diffs only
content/docs/ 1.0.0/ the complete tree 2.0.0/ only what changed next/ work in progress
Generated — gitignored, rewritten each build
versions.json versioned_docs/version-*/ versioned_sidebars/*.json .docs-overlay/current/
content/docs/prebuild: docs-overlay materializedocusaurus build
docs/ becomes build output, which will surprise every contributor — so put docs-overlay materialize --check in CI. It turns an edit made there into a failed build instead of one that disappears without a trace.
Authoring
A version folder says what it changes. Nothing else is written down, and nothing has to be repeated.
The new file, nothing else.
--- title: Intro ---
A file the older versions do not have.
--- title: New API ---
The old slug keeps answering — with a redirect from this version on.
--- title: New API overlay: renamedFrom: guide/old-api ---
A tombstone at the same path. Readers get an explanation, not a 404.
--- title: Old API overlay: deleted: true replacedBy: guide/new-api ---
All four are expressed in the version that introduces them, so a published folder is never touched again. Which means a release pull request is reviewable: grep -rl 'deleted: true' content/docs/3.0.0/ lists exactly what disappears.
The command line
cut, check and prune need only version folders, so a Fumadocs site or a plain repository of Markdown uses them exactly as a Docusaurus site does. materialize is the one that writes.
docs-overlay cut 2.0.0
The channel folder becomes that version and comes back empty, inheriting everything again. A git mv, so the content diff is zero bytes.
docs-overlay check
The engine's diagnostics in seconds, with no framework build: duplicate slugs, a tombstone with nothing to remove, a redirect that goes nowhere.
docs-overlay prune
Drops the files a version repeats byte for byte from what it inherits. The resolved site is identical afterwards — the slug is simply served by inheritance.
docs-overlay materialize
Writes the tree Docusaurus reads, as a prebuild step. --check writes nothing and fails when the generated tree is out of date.
Why
The engine is a plain TypeScript library: no npm dependency, no node: built-in, no framework — the Fumadocs and Docusaurus adapters sit on top of it. An architecture test and a packaged run with no node_modules at all keep it that way.
The list of versions is the list of folders, ordered by semver, with declared channels sorted last. Nothing to keep in sync, nothing that can drift.
latestAtRoot gives the Docusaurus URL shape — /docs/guide/a is the newest release, /docs/11.13.0/guide/a an older one. No external link breaks.
An unreachable page, a rename pointing nowhere, a navigation list that lost an entry: each is a diagnostic you can turn into a failed build rather than a reader's dead end.
getDependents() answers which versions a changed file actually feeds, so invalidate() rebuilds those and nothing else.
Packages released on separate schedules cannot share one version list. Scopes let each carry its own versions, its own channel and its own overlay chain, all behind a single loader().
Its own proof
The releases and the nextchannel are listed in the sidebar. The channel folder holds only the pages an unreleased change has rewritten — often none at all. Every other page it serves is the release's file of the same name, and says which version wrote it.
Four packages, an ESM-only install, and a site that already knows how to serve every version — on Fumadocs or on Docusaurus.
npm install docs-overlay docs-overlay-fumadocs