Versioning VitePress Documentation Without a Plugin
Libraries with users on several major versions need docs for each: the API changed, the configuration changed, and a reader on 2.x needs the 2.x page, not the latest. Docusaurus has versioning built in. VitePress does not, and the community plugins that add it copy content folders per version in the Docusaurus style, which brings Docusaurus's costs — every version rebuilt on every deploy, shared components changing old versions, and a docs folder that grows by a full copy with each release.
There is a simpler approach that fits VitePress's design: build each version from its own release branch with its own base, put the outputs side by side in one deploy, and add a small version switcher. This guide sets it up for an SDK with three supported versions (3.x current, 2.x and 1.x) and shows the CI workflow, the switcher, canonical handling and the build-time savings. It belongs to Docs Frameworks: Docusaurus, Starlight and VitePress.
Prerequisites
- Release branches for each supported major version (
release/1.x,release/2.x), with docs in the repository. - VitePress 1.x in each branch. The versions do not need to match; 1.x docs were frozen on VitePress 1.0.
- A static host that serves one directory — Cloudflare, Netlify, GitHub Pages or S3.
The Layout of the Deploy
The latest version lives at the root so that the URLs people link to and search engines index are stable — /guide/retries always means "current". Older versions live under a prefix.
Step 1: Make Each Branch Buildable With Its Own Base
In each release branch, the VitePress config reads its base from an environment variable, defaulting to the root:
// .vitepress/config.mts (in every branch)
export default defineConfig({
base: process.env.DOCS_BASE ?? '/',
head: [['meta', { name: 'docs-version', content: process.env.DOCS_VERSION ?? 'dev' }]],
});
This was a one-line backport to release/1.x and release/2.x. Nothing else in the old branches changes.
Step 2: Build Every Version in CI
A GitHub Actions workflow on main checks out each branch into its own directory, builds it with the right base and combines the results:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { ref: main, base: /, out: '' }
- { ref: release/2.x, base: /v2/, out: v2 }
- { ref: release/1.x, base: /v1/, out: v1 }
steps:
- uses: actions/checkout@v4
with: { ref: ${{ matrix.ref }} }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci && npx vitepress build docs
env: { DOCS_BASE: ${{ matrix.base }}, DOCS_VERSION: ${{ matrix.ref }} }
- uses: actions/upload-artifact@v4
with: { name: site-${{ strategy.job-index }}, path: docs/.vitepress/dist }
deploy:
needs: build
steps:
- uses: actions/download-artifact@v4 # then move each into dist/<out>/ and deploy
The three builds run in parallel. A later improvement cached each branch's output keyed by the branch's latest commit SHA, so an unchanged version is restored from cache instead of rebuilt — 1.x has not changed in four months and has not been rebuilt since.
Step 3: A Version Switcher in the Theme
versions.json is generated in the deploy job from the matrix:
[{ "label": "3.x (latest)", "path": "/" }, { "label": "2.x", "path": "/v2/" }, { "label": "1.x", "path": "/v1/" }]
The switcher is a nav dropdown, added through VitePress's nav config in the latest branch with static entries, and backported to the older branches the same way so readers can move between versions from any page. A small enhancement keeps the reader on the same page when switching if it exists: the switcher requests the target URL with fetch(url, { method: 'HEAD' }) and falls back to the version's home page on a 404.
Step 4: Banners and Canonical Links on Old Versions
Readers who arrive at /v1/guide/retries from a search engine should know they are on an old version. Each release branch's theme shows a banner at the top of every page: "You are reading docs for 1.x. The latest version is 3.x." with a link to the same page in the latest version. The banner is added when a branch stops being current, in one commit to that branch.
Old-version pages also get a canonical link to the latest page where one exists, so search engines consolidate signals on the current docs. The deploy job generates a map of which paths exist in the latest version and injects the canonical into old-version HTML with a short post-build script, since the old branches cannot know about pages added later. The sitemap includes only the latest version, as recommended in Canonical URLs and Duplicate Content on Static Sites.
Step 5: Releasing a New Major Version
When 4.x ships, the steps are mechanical: create release/3.x from the last 3.x commit on main, add a matrix entry for it with base /v3/, add the old-version banner commit to release/3.x, and update versions.json labels. Nothing in the content moves. The previous approach, a copy of the docs folder per version, would have duplicated 410 Markdown files into the repository for each release.
Backporting Doc Fixes to Old Versions
Frozen versions still need occasional fixes: a security note, a broken install command, a wrong default in a configuration table. Because each version is a branch, a fix is an ordinary pull request against release/2.x, reviewed and merged like any other, and the next deploy rebuilds only that version (its cache key changed). For fixes that apply to several versions, the team writes the fix on main first and cherry-picks it onto each release branch; a small GitHub Actions workflow triggered by a backport-2.x label on the merged pull request does the cherry-pick and opens the backport pull request automatically.
This is the main operational difference from folder-per-version setups, where a fix to an old version is an edit to a copied folder on main. Both work, but the branch approach keeps a clear record of what each version's docs looked like at each release, and makes it impossible to change an old version by accident while editing the current one. Over six months the project backported 23 fixes to 2.x and 4 to 1.x; none of them required changes to the deploy workflow.
Measured Impact
Comparison with the plugin-based copy-per-version setup the project used before:
| Measure | Copy per version | Branch per version | Tool |
|---|---|---|---|
Markdown files in main | 1,190 (3 copies) | 410 | find |
| Full deploy build, all versions | 3 min 20 s (serial) | 1 min 25 s (parallel) | GitHub Actions |
| Typical docs change deploy | 3 min 20 s | 58 s (2 versions cached) | GitHub Actions |
| Old versions changed by shared component edits | Yes | No | — |
| Search index per version | Combined, 1.4 MB | Per version, ~0.5 MB | Build output |
Pitfalls & Rollback
- Absolute links in content that ignore
base. VitePress rewrites Markdown links for the base, but hard-coded<a href="/...">in components does not; usewithBase(). - Forgetting to cache old builds. Without the cache, every deploy rebuilds every version.
- Old versions in the sitemap. Keep the sitemap to the latest version and use canonicals.
- Breaking the 404 page. Each version has its own
404.html; configure the host to use the root one, or route per prefix if the host supports it. - Rollback: the old versions are independent builds; removing a matrix entry removes that version from the next deploy without touching the others.
Conclusion
VitePress does not need a versioning plugin. Building each major version from its release branch with its own base, combining the outputs, and adding a switcher, banners and canonicals gives frozen, independent versions and faster deploys — 58 seconds for a typical change, against 3 minutes 20 seconds with folder copies.
FAQ
Does VitePress have built-in versioning like Docusaurus?
No. VitePress has no versioning feature, so each version is usually built from its own git branch or tag with a different base path, and the outputs are combined into one deploy. This keeps each version's build independent and makes old versions immutable, at the cost of a small amount of CI scripting.
Why build old versions from branches instead of copying folders?
Copying a docs folder per version, as Docusaurus does, means every version is rebuilt on every deploy and old versions pick up changes to shared components. Building from release branches freezes each version with the code and dependencies it shipped with, and only the versions that changed need rebuilding.
How do I stop search engines indexing old versions?
Add a canonical link on every old-version page pointing at the same page in the latest version where it exists, and keep old versions out of the sitemap. Avoid noindex on old versions if people still search for them by version number; canonical tags consolidate ranking without hiding the pages.
How does local search work across versions?
Each version's build produces its own search index, scoped to that version, because each is a separate VitePress build with its own base path. Readers search within the version they are reading, which is usually what they want. Cross-version search needs a separate index such as Pagefind run over the combined output.
Related
- Parent: Docs Frameworks: Docusaurus, Starlight and VitePress — how each framework handles versions.
- Versioned Documentation with Docusaurus — the built-in, folder-copy approach.
- VitePress for Library Documentation — the rest of a VitePress library docs setup.
- Matrix Builds for Multi-Site Monorepos — the CI matrix pattern in more depth.
- Adding Local Search to a VitePress Site — search per version.