Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

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 10 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 VitePressIn Blume
.vitepress/config.tsblume.config.ts
A sidebar per section ('/guide/': [...])Header tabs, one folder each
Sidebar groupsFolders 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 # H1Its frontmatter title
/guide/setup.html/guide/setup, with a redirect from the old URL
public/ inside your source folderpublic/ beside blume.config.ts
Local search, or Algolia DocSearchBuilt-in search. The algolia() adapter needs a new index: Blume replaces an index's contents when it syncs
Vue components in pagesBlume 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-blume

Then 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.txt

If 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.txt

Keep ../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 --claude

To 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:

  1. Runs the bundled VitePress codemod over your pages. It converts containers, code groups, alerts, fence labels, snippet imports, includes, badges, .html links, and H1s, renames the pages that now need MDX to .mdx, and reports everything it leaves.
  2. 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.
  3. Writes blume.config.ts from .vitepress/config: title, logo, edit links, social links, analytics, and a header tab for each sidebar.
  4. Rebuilds each sidebar as folders, meta.ts files, and sidebar frontmatter, hiding the pages VitePress left out of it.
  5. Adds a redirect from every old .html URL and pins each heading's old anchor, both generated from your VitePress build.
  6. Swaps VitePress for Blume in package.json, deletes .vitepress/, points your deploy at Blume's dist/, and runs blume build, blume validate --strict, and blume audit --only redirects until 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 ::: tip with a space, and Blume shows that as literal text without an error. Blume's directives also work only in .mdx pages. 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 a theme.css written against Blume's tokens.
  • Data loaders and dynamic routes. A .data.ts loader becomes static content, a custom Astro page, or a content source. A [slug].md page with a .paths.ts file 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/dist now publish dist/, and .gitignore lists .blume and dist. If the docs folder has a tsconfig.json, Blume's build reads it, so its extends must 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/plugins

A 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.txt

It 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-site

After 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 prev and next links, 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 like transformPageData. Blume writes its own canonical URLs, Open Graph images, and llms.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 --claude
Read the migration reference

A step here not working for you? Report a broken step.

Keep going.More guides.

  • Migrate your docs from Fern

    Hand your Fern repository to a coding agent, keep every page URL and redirect every endpoint, export a Fern Definition to OpenAPI, and deploy docs you host yourself while Fern keeps generating your SDKs.

  • Migrate your docs from Redocly

    Hand your Redocly project to a coding agent, convert Markdoc to MDX, keep every API reference URL with generated redirects, and deploy docs you host yourself.

  • Migrate your docs from VuePress

    Hand your VuePress site to a coding agent, turn its Vue-flavored Markdown into Blume pages in every language, and keep every old .html URL and heading anchor working.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init