Migrate
Migrate your docs from Docusaurus
Move a Docusaurus site's docs to Blume with a coding agent, and check the sidebars, admonitions, and React components it carries over.
By Hayden Bleasel9 min read

By the end of this guide, your Docusaurus docs are a Blume project: the same pages in the same folders, sidebars turned into folders and meta.ts files, admonitions still written as :::note, your existing URLs kept or redirected, and a site deployed where you choose. A coding agent does the conversion, and you review it.
The migration moves your docs, your blog, and your latest version. It doesn't move the React app around them. Swizzled theme components and React pages under src/pages are yours to rebuild or leave behind, and the agent lists each one.
What carries over
Docusaurus and Blume already share a lot: Markdown and MDX in folders, numeric filename prefixes for ordering, and ::: admonitions. Here's how the rest maps:
| In Docusaurus | In Blume |
|---|---|
docusaurus.config.js | blume.config.ts |
An autogenerated sidebars.js | Navigation from the folder tree, with no config |
_category_.json | A meta.ts in the same folder |
:::note, :::tip, :::caution | :::note, :::tip, :::warning, in .mdx pages |
<Tabs> of <TabItem> | <Tabs> of <Tab> |
static/ | public/, at the same URLs |
```bash npm2yarn | ```package-install |
| Blog posts | Pages with type: blog, with a feed at /blog/rss.xml |
| Mermaid and math plugins | Built in, in .mdx pages |
| OpenAPI doc plugins | openapi() in reference |
| Algolia DocSearch | Built-in local search, or the algolia() adapter |
What doesn't carry over: swizzled components in src/theme, React pages like your landing page, the blog's generated tag, author, and archive pages, footer column titles, <TOCInline />, and redirects computed by a createRedirects function. If your site leans on those, or you translate through Crowdin, read Blume vs Docusaurus first. It covers when staying on Docusaurus is the better call.
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-blumeFind the folder that holds docusaurus.config.js (or .ts). In a library's repository it's often website/. You'll run every command in this guide from there.
Check which major version of Docusaurus you're on, since version 2 and version 3 content need different fixes:
grep '"@docusaurus/core"' package.jsonThen save the URLs your site serves today. The classic preset writes a sitemap.xml in production builds, so pull the paths out of the live one and keep the list outside the repository:
curl -s https://docs.acme.example/sitemap.xml \
| grep -o '<loc>[^<]*' \
| sed -e 's#<loc>https://docs.acme.example##' -e 's#\(.\)/$#\1#' \
> ../old-urls.txtReplace docs.acme.example with your domain in both places. The last expression drops any trailing slash, since Blume's URLs never end in one. You also need Node.js 22.12 or later, and Claude Code or Codex installed and signed in.
Decide where your docs live
Docusaurus serves docs under /docs/ by default, even when your config never mentions routeBasePath. Blume serves pages from the root. Settle which you want before you start, because the agent needs the answer and every URL depends on it.
Keep /docs. Set a basePath, and every page mounts under it while the sidebar stays the same:
basePath: "/docs",You still write links as if the docs lived at the root, and Blume adds the prefix. Custom pages keep their own routes, so a landing page can still live at /. One catch: every content page mounts under basePath, the blog included, so posts move to /docs/blog/. Blume writes redirects under basePath too, so sending the old /blog/ URLs there takes a rule on your host.
Move to the root. Drop the prefix, and one pattern redirect covers every old docs URL, /docs itself included:
redirects: [{ from: "/docs/:slug*", to: "/:slug*", status: 308 }],The catch here is hosting. A static build can't turn a pattern into redirect pages, so a host that reads no redirect file, like GitHub Pages, applies only exact redirects. On GitHub Pages, keeping /docs is the smaller change.
Four more settings shape your URLs:
urlandbaseUrl. The site URL becomesdeployment.site, and abaseUrlother than/becomesdeployment.base. Blume detects the site URL on Vercel, Netlify, and Cloudflare Pages, but not on GitHub Pages, so keep it there.slugfrontmatter. Docusaurus resolves a slug without a leading slash against the page's folder. Blume'sslugis the whole route from the content root, soslug: startinguides/intro.mdbecomesslug: guides/start.idfrontmatter. Docusaurus builds a page's URL from its ID, soid: part1inguides/hello.mdserves the page at/docs/guides/part1. Blume has noidkey, and the skill drops it, so the page moves to its file path,guides/hello. Where an ID differs from its filename, replace it withslug: guides/part1.- Versions. Docusaurus serves your latest release at
/docs/and the unreleaseddocs/folder at/docs/next. The agent migrates the version readers see at/docs/. To keep older versions, see Version your docs.
Run the migration
From the folder that holds your Docusaurus config, run:
npx blume migrate docusaurus --claudeTo use Codex, swap --claude for --codex. On pnpm 12, run it with pnpm dlx --allow-build=esbuild instead of npx, since pnpm 12 won't run esbuild's install script until you approve it. Leave out docusaurus and Blume detects the source from your docusaurus.config file. Run it with neither flag and it only prints where the skill lives, for another agent to use.
blume migrate converts nothing itself. It names the source, then opens the agent in your terminal on the blume-migrate skill that ships inside the package, pointed at the skill's Docusaurus reference. The agent runs interactively, so its edits go through its usual permission prompts. Tell it your decisions up front: whether to keep /docs, which versions to keep, and whether the repository also holds a library whose scripts and dependencies must stay. Following the skill, it:
- Writes
blume.config.tsfrom your config, theme settings, presets, and plugins. - Turns sidebars into folders, and each
_category_.jsoninto ameta.ts. - Renames every
.mdpage that uses admonitions, JSX, imports, or math to.mdx, and rewrites tabs, code block options, and theme imports. - Moves
static/intopublic/, and turns blog posts intotype: blogpages with their dates in frontmatter. - Adds a redirect for every URL that moves.
- Swaps the Docusaurus dependencies for Blume, then runs
blume buildandblume validateuntil both pass.
It finishes with a summary of what it migrated, dropped, and approximated. Keep it: it's your checklist for the review.
Review the diff
Start with git diff --stat, run npx blume dev, and check each area:
- Config. Title, logo, navbar links as header tabs or links, the edit URL as
github, and the color mode. Anything left out falls back to a Blume default on purpose. - Navigation. Click through the sidebar. A category with a generated index page is now a folder with an
indexpage, and its old/docs/category/URL should have a redirect. - Category pages. The skill turns each
<DocCardList />into a<CardGroup>with a card per page, which goes stale as pages change. Setting directory: "card" in the folder'smeta.tslists them for you instead, below the index page's content, and suits a generated-index category too. - Blog. Posts should carry a
date, and the old dated URLs like/blog/2024/01/31/postshould redirect. Blume doesn't generate a blog index, so check that the agent wrote ablog/index.mdxthat links your posts. - Math. The skill treats inline
$x$as unsupported, so the agent may have moved a formula onto its own line or dropped it. Blume renders inline math written with two dollar signs, like$$x^2$$inside a sentence, so put those back. - Dependencies. Only the Docusaurus packages and scripts should be gone. If your docs share a
package.jsonwith your library, its own scripts and dependencies stay.
Pages that build but render wrong
The fixes most worth checking by hand are the ones the build can't catch. Blume reads a .md file as plain Markdown, so an admonition left in one renders as literal :::note text, and the build stays green. This lists any that remain:
grep -rl '^:::' --include='*.md' docsOn Docusaurus 2, admonition titles follow a space instead of brackets. Blume drops a title written that way without an error:
:::tip Before you deploy
Set the site URL first.
:::Blume needs the title in brackets:
:::tip[Before you deploy]
Set the site URL first.
:::These two searches find leftover v2 titles and highlight comments:
grep -rn '^:::[a-z]* [A-Za-z]' docs
grep -rn 'highlight-next-line\|highlight-start' docsDocusaurus's // highlight-next-line comments ship as literal comments in Blume. Replace each with a line range in the fence, like ```js {3}, or a // [!code highlight] comment on the line itself.
Custom components become islands
React components you import into pages from src/components need a new home. In Blume, an interactive component goes in an islands/ folder at the project root, and its filename becomes a tag you can use in any .mdx page with no import:
import { useState } from "react";
export default function Playground() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Run {count}</button>;
}Delete the import line from each page and keep the <Playground /> tag. Docusaurus APIs don't exist here: a component wrapped in <BrowserOnly> becomes an island with export const client = "only", and one that read useDocusaurusContext can read the site's config from useBlume() in blume/hooks. See Islands for the rest.
Check every old URL
With npx blume dev running, walk your saved list and print any old URL that no longer reaches a page:
while read -r path; do
code=$(curl -sL -o /dev/null -w "%{http_code}" "http://localhost:4321$path")
[ "$code" = "200" ] || echo "$code $path"
done < ../old-urls.txtRedirects count as passing, since -L follows them. Add a redirect for each path it prints, and run it again until it prints nothing. blume dev answers pattern redirects, so the loop passes even for patterns a static host like GitHub Pages ignores. Run it once more against the deployed site. For status codes, patterns, and what each host does with them, see Move documentation URLs while preserving old links.
The dev server answers a slashed URL with a 404, which is why the list has no trailing slashes. If your Docusaurus config set trailingSlash: true, links elsewhere on the web point at the slashed form, so check a few of them on the deployed site too. Hosts differ in what they do with the extra slash.
Build and validate
Stop the dev server, then build the site and check its links:
npx blume build
npx blume validate --strictblume build fails on any page, route, or config error. On a Docusaurus 2 site, expect MDX errors here: version 2 parsed content with MDX 1, which let through a bare < or { in prose, HTML comments, and style attributes written as strings. Each error names the file and line. Then blume validate --strict checks every internal link, heading anchor, and asset, and fails on warnings too. Fix what they report rather than passing --no-strict, which drops the pages that fail.
Deploy
Many Docusaurus sites live on GitHub Pages, and a Blume site can stay there. Blume can't detect your URL on GitHub Pages, so keep it as deployment.site:
import { defineConfig } from "blume";
export default defineConfig({
// ...everything the migration wrote
deployment: { site: "https://docs.acme.example" },
});A project site without a custom domain, like https://acme.github.io/widget-sdk/, splits into site: "https://acme.github.io" and base: "/widget-sdk", where Docusaurus had url and baseUrl. Deploy Markdown docs to GitHub Pages has the full Actions workflow. Keep to exact redirects there, since GitHub Pages ignores patterns. For any other host, see Deployment.
Switch over
If you published with docusaurus deploy, your last Docusaurus build is still on the gh-pages branch. Switching the Pages source to GitHub Actions for Blume leaves that branch alone, so rolling back is switching the source back. On another host, keep the old deployment until the new one has served traffic and the URL check passes against it.
Search engines follow permanent redirects, but they recrawl on their own schedule. Submit the new sitemap.xml in Google Search Console and give results time to settle.
What you run now
There's no React app left to upgrade: no swizzled components to keep in step with the theme, and no plugins to update. In exchange, you give up swizzling and the plugin API. Blume's version is layout slots for replacing pieces of the page chrome, and blume eject when you want the whole Astro app. Translations move from Crowdin to blume translate, if you had them.
Troubleshooting
A page shows :::note as text
The page is a .md file. Rename it to .mdx, where directives work.
The build fails on a page's frontmatter
Blume's frontmatter schema is strict, and BLUME_FRONTMATTER_INVALID names the page and key. Docusaurus keys like pagination_next, hide_title, and sidebar_class_name have no Blume equivalent, so remove them. sidebar_label and sidebar_position map to sidebar.label and sidebar.order, and an id that set the URL becomes a slug.
Images 404
Docusaurus serves static/ at the site root and Blume serves public/. Move the folder's contents across, and every root-relative path keeps working.
A version folder shows up as current docs
BLUME_VERSIONS_UNCONFIGURED_VERSION means a version-shaped folder isn't listed in versions.archived. Add it there, or delete the folder if you're only keeping the latest version.
A redirect works locally but not on GitHub Pages
It's a pattern. Replace it with one exact redirect per old URL.
A redirect fails the build
BLUME_REDIRECT_MATCHES_PAGE means a pattern also covers a page you kept. Narrow it to the URLs that moved.
Next step
Migrate your docs
Run it at the root of your Docusaurus site, on a clean branch, then work through the review above.
npx blume migrate docusaurus --claudeA step here not working for you? Report a broken step.