Migrate
Migrate your docs from VitePress
Hand your VitePress site to a coding agent, convert its Markdown extensions with a codemod, rebuild sidebar groups without moving URLs, and keep every old .html address and heading anchor working.
By Hayden Bleasel10 min read

By the end of this guide, your VitePress docs are a Blume project: your containers and code groups converted, your snippet imports turned into includes, your sidebars rebuilt as folders and tabs without moving a page, and every old .html URL redirecting to its page. A coding agent does the conversion, starting with a codemod for the mechanical part, and you review it.
It's written for VitePress 1.x sites on the default theme or a theme that extends it. The Markdown carries over; the Vue around it doesn't. Custom theme components, data loaders, and dynamic routes are yours to rebuild or drop, and the agent lists each one.
What carries over
The run behind this guide migrated a real VitePress site: 106 pages with a custom theme, 444 snippet imports, and a sidebar per section. Every page carried over, and all 107 old URLs reached a page. Here's how the pieces map:
| In VitePress | In Blume |
|---|---|
.vitepress/config.ts | blume.config.ts |
A sidebar per section ('/guide/': [...]) | Header tabs, one folder each |
| Sidebar groups | Folders with a meta.ts; (group) folders when the pages share one folder, so URLs don't change |
::: tip, ::: warning Title | :::tip, :::warning[Title], in .mdx pages |
::: details | <Expandable> |
::: code-group and [label] fences | <CodeGroup>, each block titled with its label |
> [!NOTE] alerts | :::note and the other callouts |
<<< @/snippets/x.ts and <!--@include: --> | <include> |
```js{2}, :line-numbers, // [!code ++] | ```js {2}, lineNumbers, the same comment |
<Badge type="tip" text="v2" /> | <Badge variant="accent">v2</Badge> |
The page's # H1 | Its frontmatter title |
/guide/setup.html | /guide/setup, with a redirect from the old URL |
public/ inside your source folder | public/ beside blume.config.ts |
| Local search, or Algolia DocSearch | Built-in search. The algolia() adapter needs a new index: Blume replaces an index's contents when it syncs |
| Vue components in pages | Blume components, Markdown, or Vue islands |
Here's one page before and after the codemod:
# Install
::: tip Before you start
You need Node.js 22.
:::
::: code-group
```sh [npm]
npm install @acme/sdk
```
```sh [pnpm]
pnpm add @acme/sdk
```
:::
<<< @/snippets/client.ts{2} [client.ts]
> [!WARNING]
> Keep your key out of the browser.
Next, read the [configuration](./configuration.html) guide.---
title: Install
---
:::tip[Before you start]
You need Node.js 22.
:::
<CodeGroup>
```sh npm
npm install @acme/sdk
```
```sh pnpm
pnpm add @acme/sdk
```
</CodeGroup>
<include meta="client.ts {2}">/snippets/client.ts</include>
:::warning
Keep your key out of the browser.
:::
Next, read the [configuration](/guide/configuration) guide.Before you start
The agent edits your repository in place, so start on a new branch with a clean working tree:
git switch -c migrate-to-blumeThen build your VitePress site once, outside the .vitepress/ folder the agent will delete. The build is your list of old URLs, the record of what your live site rendered, and the source of its heading anchors. From the folder whose package.json runs VitePress, with its dependencies installed, run this, naming the folder your build script passes to vitepress build (docs, or . when the docs are their own package):
npx vitepress build docs --outDir ../old-site
find ../old-site -name '*.html' | sed 's#^\.\./old-site##' | sort > ../old-urls.txtIf your site set cleanUrls: true, its URLs never ended in .html, and they already match Blume's. Write the list without it instead:
find ../old-site -name '*.html' ! -name 404.html \
| sed -e 's#^\.\./old-site##' -e 's#index\.html$##' -e 's#\.html$##' \
| sort > ../old-urls.txtKeep ../old-site until you're done. You also need Node.js 22.19 or later, and Claude Code or Codex installed and signed in.
Run the migration
From the same folder, run:
npx blume migrate vitepress --claudeTo use Codex, swap --claude for --codex. Leave out vitepress and Blume detects the source from a .vitepress/config file at the root or in docs/, or a vitepress dependency.
blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its VitePress reference. Following it, the agent:
- Runs the bundled VitePress codemod over your pages. It converts containers, code groups, alerts, fence labels, snippet imports, includes, badges,
.htmllinks, and H1s, renames the pages that now need MDX to.mdx, and reports everything it leaves. - Works through that report: frontmatter keys Blume rejects, Vue components, snippets with regions or from outside the docs, and containers whose structure it had to repair.
- Writes
blume.config.tsfrom.vitepress/config: title, logo, edit links, social links, analytics, and a header tab for each sidebar. - Rebuilds each sidebar as folders,
meta.tsfiles, andsidebarfrontmatter, hiding the pages VitePress left out of it. - Adds a redirect from every old
.htmlURL and pins each heading's old anchor, both generated from your VitePress build. - Swaps VitePress for Blume in
package.json, deletes.vitepress/, points your deploy at Blume'sdist/, and runsblume build,blume validate --strict, andblume audit --only redirectsuntil they pass.
The codemod's report looks like this, one block per page:
guide/install.md → guide/install.mdx
1 × ::: code-group → <CodeGroup>
1 × ::: type Title → :::type[Title]
1 × <<< import → <include>
1 × GitHub alert → directive
1 × body H1 → frontmatter title
2 × fence [label] → title
1 × link → route
guide/advanced.md → guide/advanced.mdx
1 × ::: code-group → <CodeGroup>
1 × ::: type → :::type
1 × duplicate body H1 removed
2 × fence [label] → title
1 × unclosed code group ended at its last code block
REVIEW line 3: frontmatter `outline` isn't a Blume key (the schema is strict): …
REVIEW line 18: code group has no closer before line 26; it now ends at its last code block …
REVIEW line 39: component <Playground>: map it to a Blume component, an island, or Markdown
REVIEW line 51: `<<<` region #setup: <include> has no region selection; …The redirects and anchor pins come from your old build, so if the agent asks for it or starts rebuilding VitePress, point it at ../old-site. It ends with a summary of what it migrated, dropped, and approximated. Keep it for the review.
Review the changes
Start with git diff --stat, run npx blume dev, and work through these, which are where a VitePress migration most often needs a second look.
- Containers. VitePress writes
::: tipwith a space, and Blume shows that as literal text without an error. Blume's directives also work only in.mdxpages. The first two searches below should print nothing. - Repaired structure. VitePress pairs a bare
:::with the outermost open container, so sites collect unclosed code groups that swallow the next callout, and stray:::lines that show as text. The codemod repairs each one, ending a code group at its last code block and dropping a stray:::, and reports it. Compare those pages with the old build in../old-site; a missing opener, for one, needs you to put the group back. - Vue components. Each component the theme registered becomes a Blume component, plain Markdown, or an island, or it's dropped. Check the old build first: a component the theme never registered rendered nothing there, so converting it shows content readers never saw. A component that read API data or contributor lists usually goes, so look for sentences that now lead into nothing.
- The theme. Layout slots, custom CSS, and theme-package home sections don't carry over. Your brand color becomes
theme.accent, and CSS worth keeping moves to atheme.csswritten against Blume's tokens. - Data loaders and dynamic routes. A
.data.tsloader becomes static content, a custom Astro page, or a content source. A[slug].mdpage with a.paths.tsfile becomes one real page per path. - Navigation. Click through each tab. Groups that lived only in your sidebar config are now
(group)folders, which add a sidebar group without adding a URL segment. Pages your sidebar didn't list are hidden from Blume's sidebar but still built. - Titles. VitePress showed each page's H1, so the H1 is now its frontmatter
title. A title that had inline code or a badge in it is left for you. - The repository. CI and publish scripts that pointed at
.vitepress/distnow publishdist/, and.gitignorelists.blumeanddist. If the docs folder has atsconfig.json, Blume's build reads it, so itsextendsmust resolve from there.
Run from your content folder (Blume's content.root), these print spaced containers, directives left in .md pages, Vue syntax left in .mdx pages, and internal .html links:
grep -rn '^ *::: ' . --include='*.md' --include='*.mdx' \
--exclude-dir={node_modules,dist,public,.blume}
grep -rln '^:::' . --include='*.md' --exclude-dir={node_modules,dist,public,.blume}
grep -rnE '\{\{|<script|<ClientOnly| :[a-z-]+="' . --include='*.mdx' \
--exclude-dir={node_modules,dist,public,.blume}
grep -rn '\.html' . --include='*.md' --include='*.mdx' \
--exclude-dir={node_modules,dist,public,.blume} | grep -v '://'Sidebar groups that existed only in config end up as folders like these, which leave every URL where it was:
docs/
guide/
meta.ts
(basics)/
meta.ts title: Basics
install.mdx still /guide/install
configuration.mdx
(advanced)/
meta.ts title: Advanced
plugins.mdx still /guide/pluginsA page you move one folder deeper keeps its URL, but not its relative links: a ./images/diagram.png or ../setup.md in it now points one level off. blume build fails on a missing image, and blume validate --strict reports the broken links.
An interactive Vue component can stay Vue. Drop it in an islands/ folder, install @astrojs/vue and vue, and use it in any .mdx page with no import, passing props in JSX form (:start="3" becomes start={3}):
<script setup>
import { ref } from "vue";
const props = defineProps({ start: { type: Number, default: 0 } });
const count = ref(props.start);
</script>
<template>
<button @click="count++">Clicked {{ count }}</button>
</template>A component that imports from vitepress, like useData() or a theme part, doesn't run outside VitePress. See Islands for hydration options.
Check every old URL
Build the site, then check that each URL in your saved list is either a page Blume built or an old .html address with a redirect to one. Save this in the folder you built VitePress from, which now holds blume.config.ts:
import { existsSync, readFileSync, statSync } from "node:fs";
const lines = (file) => readFileSync(file, "utf8").split("\n").filter(Boolean);
const isFile = (path) => existsSync(path) && statSync(path).isFile();
const isPage = (route) => isFile(`dist${route === "/" ? "" : route}/index.html`);
const rules = existsSync("dist/_redirects") ? lines("dist/_redirects") : [];
const redirects = new Map(rules.map((line) => line.split(/\s+/)));
let missing = 0;
for (const url of lines(process.argv[2])) {
// A file served as is: 404.html, index.html, anything from public/.
if (isFile(`dist${url}`)) continue;
// An old /x.html needs a redirect; /x/ and /x/index.html are the page itself.
const route = url.replace(/(?:\/index)?\.html$|\/$/, "") || "/";
const pageUrl = !url.endsWith(".html") || url.endsWith("/index.html");
const target = redirects.get(url) ?? (pageUrl ? route : undefined);
if (!(target && isPage(target))) {
console.log(`MISSING ${url}${target ? ` -> ${target}` : " (no redirect)"}`);
missing += 1;
}
}
console.log(missing === 0 ? "Every old URL reaches a page." : `${missing} old URL(s) don't.`);Run it after a build:
npx blume build
node check-urls.mjs ../old-urls.txtIt reads dist/_redirects, which a static build writes for Netlify, Cloudflare, or no named host. Add a redirect for each URL it prints and run it again until it prints nothing. Index pages need no redirect: /guide/ and /guide/index.html already serve the page Blume writes at guide/index.html, and a redirect from /guide/ would replace the page. npx blume preview serves the built pages and answers your redirects the way the host files the build writes do, so you can also click through a few old URLs there. It redirects a trailing-slash URL like /guide/ to /guide.
Check the heading anchors
VitePress and Blume build heading ids differently. VitePress turns punctuation into dashes, and Blume drops it, so ## What's new? moves from #what-s-new to #whats-new. On a big site, hundreds change. The agent pins each old id with a script that compares every heading in your old build with the new one. To check its work, run it from the same folder:
npx blume build
node node_modules/blume/skills/blume-migrate/scripts/pin-heading-ids.mjs --old ../old-siteAfter the agent's pass it should find nothing to pin. If it finds some, add --write, rebuild, and run it again until it reports 0: a pin can renumber a later heading with the same text. A NOTE names a heading it couldn't pin, often one that went with a dropped component. Pin one that still exists by hand with a trailing marker: ## What's new? [#what-s-new]. Then run npx blume validate --strict, which checks every link to an anchor, including ones that were already broken on your old site.
Deploy and switch over
Many VitePress sites deploy to GitHub Pages, and a Blume site can stay there. Blume can't detect your URL on GitHub Pages, so set it as deployment.site, and keep a VitePress base as deployment.base:
deployment: { site: "https://docs.acme.example" },Point your workflow's upload at dist instead of .vitepress/dist, and make sure it installs your docs package's dependencies. Deploy Markdown docs to GitHub Pages has a complete workflow; for other hosts, see Deployment.
On Netlify, name the host even for a static build: deployment: netlify({ output: "static" }), with netlify imported from blume/deploy. Only then are the _redirects rules forced, so an old .html URL gets a real 301 instead of the redirect page.
Before switching your domain, deploy a preview and open a few old .html URLs on it, since hosts differ in how they serve redirects. Keep the old deployment until the new one passes, so rolling back is a DNS or Pages-source change. Then submit the new sitemap.xml in Google Search Console, since search engines recrawl moved URLs on their own schedule.
What doesn't carry over
- The Vue app. Custom themes, layout slots, data loaders, dynamic routes, and Vue syntax in pages (
{{ }},<script setup>,v-if) become static content, Blume components, or islands. - Some Markdown extensions.
[[toc]], emoji shortcodes, line numbers that start past 1, snippet regions, and include line ranges. Regions become their own snippet files. - Page settings. Per-page outline levels, one-sided
prevandnextlinks, and the switches for the navbar, footer, and edit link. - Site settings.
titleTemplate, the footer message and copyright, a forced color mode (Blume always shows the toggle), and build hooks liketransformPageData. Blume writes its own canonical URLs, Open Graph images, andllms.txt.
Troubleshooting
A page shows ::: tip as text
Remove the space after the colons (:::tip), and make sure the page is .mdx. A title goes in brackets: :::tip[Before you start].
An SVG warns BLUME_SVG_UNOPTIMIZED
The SVG is usually a draw.io (diagrams.net) export, whose content attribute hides the size the image optimizer reads, so Blume serves the file as it is. It still shows. To have it optimized, remove that attribute from the <svg> tag.
The build fails with BLUME_TSCONFIG_EXTENDS
The tsconfig.json beside blume.config.ts extends a package that isn't installed there, often a workspace package. Install the workspace from its root, or delete the file if only VitePress used it.
Next step
Migrate your docs
Run it in the folder whose package.json runs VitePress, on a clean branch, after saving your old build. Then work through the review above.
npx blume migrate vitepress --claudeA step here not working for you? Report a broken step.