Migrating MkDocs Documentation to VitePress
MkDocs with the Material theme is one of the most popular documentation setups, especially in Python projects. Teams move off it for a few recurring reasons: the rest of their tooling is JavaScript and the Python build environment is the odd one out; they want Vue components in their docs; or build times on large sites have grown. VitePress is a natural destination for teams in the Vue and Vite ecosystem, because it keeps the same model — Markdown files plus a config — while replacing the Python toolchain.
This guide migrates the docs for a JavaScript SDK — 380 pages, MkDocs 1.6 with Material 9.5, admonitions, content tabs, snippets and a custom domain — to VitePress 1.5, keeps every URL working, and measures the result. It belongs to Docs Frameworks: Docusaurus, Starlight and VitePress; the companion guide Migrating from MkDocs to Starlight covers the same move to Starlight.
Prerequisites
- The MkDocs site building cleanly, with its list of output URLs captured.
- Node.js 20 and a new branch for the migration.
- An inventory of Markdown extensions used, from the
markdown_extensionslist inmkdocs.yml.
Step 1: Capture URLs and Inventory Extensions
mkdocs build -d /tmp/mkdocs-site
find /tmp/mkdocs-site -name index.html | sed 's#/tmp/mkdocs-site##; s#index.html$##' | sort > urls-before.txt
grep -rhoE '^(!!!|\?\?\?|===|--8<--)' docs | sort | uniq -c
The site had 380 pages and used:
| MkDocs syntax | Occurrences | VitePress equivalent |
|---|---|---|
!!! note / !!! warning admonitions | 612 | ::: tip / ::: warning containers |
??? note collapsible admonitions | 48 | ::: details |
=== "npm" content tabs | 214 | ::: code-group for code, custom component otherwise |
--8<-- snippets | 37 | <!--@include: ./file.md--> |
{ .class } attribute lists | 22 | Mostly removed; 4 kept via markdown-it-attrs |
| Abbreviations | 1 file | Dropped |
Step 2: Convert the Syntax With a Script
Admonitions in MkDocs are indented blocks; VitePress containers are fenced. A Node script handles the indentation-to-fence conversion, the type mapping and titles:
const TYPE = { note: 'info', info: 'info', tip: 'tip', warning: 'warning', danger: 'danger', example: 'details' };
function convertAdmonitions(src) {
return src.replace(/^(!!!|\?\?\?)\s+(\w+)(?:\s+"([^"]*)")?\n((?:(?: {4}|\t).*\n|\n)+)/gm,
(_, kind, type, title, body) => {
const t = kind === '???' ? 'details' : (TYPE[type] ?? 'info');
const inner = body.replace(/^(?: {4}|\t)/gm, '').trimEnd();
return `::: ${t}${title ? ' ' + title : ''}\n${inner}\n:::\n\n`;
});
}
Content tabs became ::: code-group when every tab held one code block — 191 of the 214 — with the tab label moved into the code fence as [npm]. The other 23 held prose and code together and used a small <Tabs> Vue component registered in the theme. Snippets became VitePress includes, with paths adjusted from the snippets base directory to relative paths.
After the script, a grep for !!!, ???, === and --8<-- returned nothing, and a visual review of 40 pages found six where indentation inside lists had confused the conversion; those were fixed by hand.
Step 3: Generate the Sidebar From mkdocs.yml
The nav tree maps directly onto VitePress's sidebar. A one-off script reads mkdocs.yml and writes .vitepress/sidebar.ts:
import yaml from 'js-yaml';
const nav = yaml.load(fs.readFileSync('mkdocs.yml', 'utf8')).nav;
const toLink = (p) => '/' + p.replace(/(index)?\.md$/, '');
const walk = (items) => items.map((it) => {
const [text, val] = Object.entries(it)[0];
return Array.isArray(val) ? { text, collapsed: true, items: walk(val) } : { text, link: toLink(val) };
});
fs.writeFileSync('.vitepress/sidebar.ts', `export default ${JSON.stringify(walk(nav), null, 2)}`);
The generated file is now maintained by hand. Top-level nav sections became VitePress's top navigation bar, with a separate sidebar per section.
Step 4: Keep Every URL
MkDocs served /guide/retries/ (directory URLs). VitePress with cleanUrls: true links to /guide/retries and writes guide/retries.html. To keep the old slash-terminated URLs working, the site deploys to Cloudflare, which serves retries.html for /guide/retries automatically and redirects /guide/retries/ to the clean form with a 308. That kept every old link working, with one redirect hop for links that included the slash. Teams that want no hop can instead keep the trailing-slash form by not enabling cleanUrls and arranging files as retries/index.md.
Comparing URL lists after the build:
npx vitepress build docs
find docs/.vitepress/dist -name '*.html' | sed 's#docs/.vitepress/dist##; s#\.html$##; s#/index$#/#' | sort > urls-after.txt
diff <(sed 's#/$##' urls-before.txt) <(sed 's#/$##' urls-after.txt)
Three pages differed — MkDocs had used README.md as an index, which VitePress does not — and got explicit redirects.
Step 5: Replace Material Features You Relied On
Material for MkDocs includes features VitePress handles differently:
- Search. Material's lunr-based search became VitePress local search; tuning is covered in Adding Local Search to a VitePress Site.
- Social cards. Material generated Open Graph images with a plugin. The site now generates them with a small script using Satori at build time, as in Generating Open Graph Images at Build Time.
- "Edit this page" and last-updated dates. Both are built into VitePress (
editLink,lastUpdated). - Versioning with mike. The site used
mikefor two versions; this became branch-per-version builds, described in Versioning VitePress Documentation Without a Plugin.
Running Both During the Transition
The migration branch lived for three weeks while writers kept editing the MkDocs source on main. Rather than freeze content, the conversion script was rerun against main before merging, so late edits were converted with the same rules. Writers previewed the VitePress build on a preview deploy of the branch, and filed issues for pages that looked wrong. On the day of the switch, the script ran one last time, the branch merged, and the CI workflow changed from mkdocs gh-deploy to the VitePress build and deploy in the same pull request. The Python requirements file was removed a week later, after nothing had needed it.
What Writers Noticed
The writers' experience changed less than expected. They still edit Markdown files in the same folders, preview locally with one command (npm run docs:dev instead of mkdocs serve), and open pull requests the same way. Three differences came up in the first month. The dev server reloads a changed page in well under a second, against two to four seconds with MkDocs on this site, which writers liked. Admonition syntax changed, so a cheat sheet in the contributing guide showed the new container forms next to the old ones. And VitePress reports Markdown and Vue errors in the browser overlay rather than the terminal, which took a day to get used to. None of the writers asked to go back.
Measured Impact
| Measure | MkDocs Material | VitePress | Tool |
|---|---|---|---|
| Cold build, 380 pages | 54 s | 11 s | hyperfine, 5 runs |
| CI job including install | 1 min 52 s | 48 s | GitHub Actions |
| Render-blocking CSS | 46 KB | 21 KB | Lighthouse |
| JavaScript per page | 39 KB | 72 KB | DevTools network panel |
| LCP, mobile median | 1.5 s | 1.3 s | Lighthouse 12 |
| Broken URLs after migration | — | 0 | URL diff + edge 404 log |
VitePress ships more JavaScript, because it hydrates pages for client-side navigation, but less render-blocking CSS, which is what moved LCP.
Pitfalls & Rollback
- Indented admonitions inside lists. The script misreads some; review pages with nested lists by eye.
- Content tabs holding prose.
code-groupis for code only; use a component for mixed tabs. - Forgetting
README.mdindexes. They change URLs; redirect them. - Dropping features without noticing. List the Material features you use before starting.
- Rollback: keep the MkDocs config and workflow on a branch for a month; the Markdown changes are mechanical and the script can be run in reverse for admonitions if needed.
Conclusion
Moving from MkDocs Material to VitePress is mostly syntax conversion and URL care. Admonitions, tabs and snippets convert with a script, the sidebar comes from mkdocs.yml, and a URL diff proves nothing broke. For this 380-page SDK site, the build went from 54 seconds to 11 and the docs toolchain went from Python plus Node to Node alone.
FAQ
Can VitePress read MkDocs Markdown files directly?
Mostly. Plain Markdown, tables, code blocks and links work unchanged. MkDocs extensions do not: admonitions written with three exclamation marks, content tabs with three equals signs, snippets includes, abbreviations and attribute lists all need converting to VitePress containers, code groups and includes. A script handles the common ones; a few pages need hand editing.
Do MkDocs and VitePress produce the same URLs?
Not by default. MkDocs with directory URLs serves pages at paths ending in a slash, while VitePress serves page.html with clean URLs as page without the extension. Setting cleanUrls in VitePress and configuring the host to serve the HTML files at slash-terminated paths, or adding redirects, keeps existing links working.
What replaces the nav section of mkdocs.yml?
VitePress's themeConfig sidebar, which is a JavaScript structure of groups and links. The nav tree in mkdocs.yml maps onto it closely, so a short script can generate the sidebar from mkdocs.yml once, after which it is maintained by hand or generated from the folder structure.
Is VitePress faster than MkDocs?
On the 380-page site in this guide, VitePress built in 11 seconds against 54 seconds for MkDocs Material with its default plugins, and pages shipped less render-blocking CSS. MkDocs remains a good choice for Python teams who want to stay in Python tooling; the migration is mainly worth it for teams already working in the JavaScript ecosystem.
Related
- Parent: Docs Frameworks: Docusaurus, Starlight and VitePress — choosing between the frameworks.
- Migrating from MkDocs to Starlight — the same migration to Starlight.
- VitePress for Library Documentation — setting up the destination well.
- Keeping Redirects Working After an SSG Migration — redirect maps for changed URLs.
- Auditing Content Parity After a Migration — checking nothing was lost.