Migrate
Migrate your docs from Fumadocs
Hand your Fumadocs repository to a coding agent, check its meta.ts and component rewrites, keep your docs at /docs, and deploy with every old URL working.
By Hayden Bleasel11 min read

Most of it. A Fumadocs site is already MDX pages in a folder tree, ordered by a meta.json in each folder, and that's the model Blume uses. Your pages move with targeted rewrites to callouts, cards, tabs, and icon names, each meta.json becomes a meta.ts, and your docs stay at /docs. What you rebuild is the React app around the pages: the home page, custom layouts, and your own components.
By the end of this guide, a coding agent has converted your repository, you have checked its diff against a worked example, every old URL resolves, and the site is live on a preview deployment. The guide follows the Next.js project that create-fumadocs-app scaffolds. React Router, TanStack Start, Waku, and Astro setups migrate the same way.
If your docs are part of a larger app and share its sign-in, components, or request-time data, stay on Fumadocs: Blume builds a separate site. To mount one under /docs of an existing Next.js app, see Serve a separate documentation site under /docs in Next.js, and Blume vs Fumadocs for the rest of the trade-off.
What carries over
A lot already works as written: title and description frontmatter, <Steps>, <TypeTable>, <GithubInfo>, <include> partials, (group) folders that add no URL segment, and heading markers like [#custom-id] and [!toc]. The rest maps like this:
| In Fumadocs | In Blume |
|---|---|
The loader's baseUrl: "/docs" | basePath: "/docs" in blume.config.ts |
content/docs/ | docs/, at the same URLs |
meta.json | meta.ts |
Folders with "root": true | Header tabs in navigation.tabs |
"defaultOpen": true | collapsed: false |
<Callout type="warn"> | :::warning, and the other types to their directives |
<Cards> of <Card icon={<Cpu />}> | <CardGroup> of <Card icon="cpu"> |
<Accordions> of <Accordion> | <Accordion> of <AccordionItem> |
<Tabs items={[…]}> of <Tab value> | <Tabs> of <Tab title> |
<Files>, <Folder>, <File> | <Tree>, <Tree.Folder>, <Tree.File> |
```npm fences | ```package-install |
lucide-react names like BookOpen | Lucide names like book-open |
Inline $x$ math from remark-math | $$x$$ inside the sentence; block $$ math works as written |
Pages generated by fumadocs-openapi | openapi() in reference, a page per operation |
Route handlers for search, llms.txt, Markdown, and OG images | Built in |
What doesn't carry over: the home page and anything else in app/ outside the docs route, header settings in lib/layout.shared.tsx (you carry those over by hand), a folder's description, reversed (z...a) and extracted (...folder) ordering, <DynamicCodeBlock>, <InlineTOC>, and custom remark, rehype, or Shiki plugins. A full: true page has no exact match: mode: wide is the closest, a full-width page without the table of contents.
Before you start
Start on a clean branch, so the whole migration is one diff:
git switch -c migrate-to-blumeThen find where your setup keeps things:
lib/source.tsholds the loader. Note itsbaseUrl(in a scaffolded project, thedocsRouteconstant inlib/shared.ts) and anyi18noption. Newer projects also define the content collection here, withfumadocs-mdx/macro.source.config.ts, in projects that use the Config API, holdsmdxOptionsand your own remark, rehype, and Shiki plugins.components/mdx.tsx(ormdx-components.tsx) registers components pages use without importing them.app/holds the home page, the docs layout, and the route handlers.
Count the components your pages use, to check each one later. Skip any type parameters from code samples in the list:
grep -rhoE '<[A-Z][A-Za-z.]*' content/docs | sort | uniq -c | sort -rnNext, save the URLs your docs serve today. Fumadocs builds each URL from the file path, so this lists them from the content folder, dropping extensions, index, and (group) folders:
find content/docs -name '*.md' -o -name '*.mdx' \
| sed -E -e 's#^content/docs#/docs#' -e 's#/\([^/]+\)##g' \
-e 's#\.mdx?$##' -e 's#/index$##' \
| sort > ../old-urls.txtIf your baseUrl isn't /docs, change the #/docs# replacement to match. Run it before the migration, while any generated API pages still exist. You also need Node.js 22.12 or later, and Claude Code or Codex installed and signed in.
Run the migration
From the folder that holds your docs' package.json, run:
npx blume migrate fumadocs --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 fumadocs and Blume detects the source from a source.config.ts or a fumadocs-core, fumadocs-ui, or fumadocs-mdx dependency. 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 Fumadocs reference. The agent runs interactively, so its edits go through its usual permission prompts. If the repository also holds a product app whose routes and dependencies must stay, say so first: the skill removes the host framework along with Fumadocs. Following the skill, it:
- Writes
blume.config.ts, with a title from yourpackage.jsonname and abasePathfrom your loader'sbaseUrl. - Moves the pages into
docs/, converts eachmeta.jsonto ameta.ts, and turns root folders into header tabs. - Rewrites components and icon names, converts
```npmfences, and removes the imports pages no longer need. - Replaces generated OpenAPI pages with Blume's
openapi()reference. - Adds a redirect for every URL that moves.
- Points
dev,build, andstartatblume dev,blume build, andblume preview, removes Fumadocs and Next.js, deletes the app files, then runsblume 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.
A migration, before and after
Here's a small site on fumadocs-ui 16.15.15 and Next.js 16, then the Blume files a correct migration ends with. Expect the same kinds of change in your diff. The site has a Guides folder and an SDK root folder:
content/docs/
index.mdx
guides/
meta.json
install.mdx
configure.mdx
internal-notes.mdx
sdk/
meta.json
index.mdx{
"title": "Guides",
"icon": "BookOpen",
"defaultOpen": true,
"pages": ["install", "configure"]
}{
"title": "SDK",
"description": "Client libraries for the Acme API",
"icon": "Package",
"root": true
}---
title: Install the SDK
description: Add the Acme SDK to a Node.js project.
icon: Download
---
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { KeyRound } from "lucide-react";
<Callout type="warn" title="Node.js 20 or later">
The SDK uses the built-in `fetch`.
</Callout>
## Install the package
```npm
npm install @acme/sdk
```
## Create a client [#create-client]
<Tabs items={["TypeScript", "JavaScript"]}>
<Tab value="TypeScript">
```ts
import { Acme } from "@acme/sdk";
export const acme = new Acme({ apiKey: process.env.ACME_API_KEY });
```
</Tab>
<Tab value="JavaScript">
```js
const { Acme } = require("@acme/sdk");
module.exports.acme = new Acme({ apiKey: process.env.ACME_API_KEY });
```
</Tab>
</Tabs>
<Cards>
<Card
icon={<KeyRound />}
title="Configure the client"
description="Set your API key and region."
href="/docs/guides/configure"
/>
</Cards>Guides names two of its three pages, so Fumadocs serves internal-notes but leaves it out of the sidebar. SDK is a root folder, so its pages show only their own sidebar. After the migration:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Docs",
basePath: "/docs",
navigation: {
sidebar: { display: "group" },
tabs: [{ label: "SDK", path: "/sdk", icon: "package" }],
},
});import { defineMeta } from "blume";
export default defineMeta({
title: "Guides",
icon: "book-open",
collapsed: false,
pages: ["install", "configure"],
});import { defineMeta } from "blume";
export default defineMeta({
title: "SDK",
icon: "package",
});---
title: Install the SDK
description: Add the Acme SDK to a Node.js project.
icon: download
---
:::warning[Node.js 20 or later]
The SDK uses the built-in `fetch`.
:::
## Install the package
```package-install
npm install @acme/sdk
```
## Create a client [#create-client]
<Tabs>
<Tab title="TypeScript">
```ts
import { Acme } from "@acme/sdk";
export const acme = new Acme({ apiKey: process.env.ACME_API_KEY });
```
</Tab>
<Tab title="JavaScript">
```js
const { Acme } = require("@acme/sdk");
module.exports.acme = new Acme({ apiKey: process.env.ACME_API_KEY });
```
</Tab>
</Tabs>
<CardGroup>
<Card icon="key-round" title="Configure the client" href="/docs/guides/configure">
Set your API key and region.
</Card>
</CardGroup>---
title: Release notes for the team
sidebar:
hidden: true
---What changed, and why:
- The tab path skips
/docs. Blume adds thebasePath, so the tab opens/docs/sdk. The folder'sdescriptionis gone, sincemeta.tshas no such field. display: "group"keeps folders collapsible. Fumadocs folders collapse by default. Blume's default is flat headers, wherecollapseddoes nothing, so add it if your diff lacks it.- The imports are gone. Blume's components need none. The card's icon is a name, and its description moved into the card body.
- The card's link keeps
/docs. Blume doesn't add thebasePathtwice, so links written with it still work. internal-notesis hidden by frontmatter, because Blume lists the pages ameta.tsleaves out.
Review the diff
Start with git diff --stat, run npx blume dev, and open http://localhost:4321/docs. Then check each area:
- Config. The title comes from your
package.jsonname. If your header showednav.titlefromlib/layout.shared.tsxinstead, settitleto it. That file'sgithubUrlbecomesgithub: { owner, repo }, for the repository and edit links, and its header links becomenavigation.actions. - Sidebar. Click through every folder and tab. A
---Section---separator should now be a(Section)folder holding its pages, and an extracted folder stays a group. - Icons. Every sidebar, tab, and card icon should render.
- Components. Open a page that uses each component in your count.
- Translations. A loader with
i18nbecomes Blume'si18n. Locale folders work as they are, and suffixed files likeinstall.cn.mdxwork with parser: "dot". Onemeta.tsthen serves every language, so folder titles from ameta.cn.jsondon't carry over. - API reference. It needs a tab in
navigation.tabspointing at its route, and its operation URLs come from each operation's tag and ID, so old ones may need redirects. See Generate API docs from an OpenAPI spec.
Pages your sidebar used to hide
Fumadocs hides a page two ways: a pages list without "..." leaves out every page it doesn't name, and a "!name" entry excludes one page from "...". Blume appends the pages a meta.ts doesn't name, so both kinds come back. Give each one sidebar.hidden: true. It stays built and reachable by URL, as before.
Your own components
Components registered in components/mdx.tsx, or imported from your own code, need a new home before that file goes. An interactive one becomes an island: a file in islands/ whose name is the tag, used in any page with no import. A static one can be an .astro component registered in components.ts. Uses of next/link and next/image become plain Markdown links and images.
Keep your docs at /docs
basePath: "/docs" mounts every page under /docs, as your loader's baseUrl did, without adding a Docs group to the sidebar. In config, write tab paths, links, and redirects as if the docs lived at the root, and Blume adds the prefix. The URLs, canonical tags, sitemap, and search index all follow it.
Your site root is the one URL it doesn't cover. In Fumadocs, app/(home)/page.tsx answered /, and it goes with the rest of app/. Rebuild it as a custom page. Blume serves pages/index.astro at /, outside the basePath, with the site header and search:
---
import PageLayout from "blume/components/layout/PageLayout.astro";
import data from "blume:data";
const { config } = data;
---
<PageLayout
site={{ title: config.title, description: config.description }}
logo={config.logo}
banner={config.banner}
analytics={config.analytics}
favicon={config.favicon}
navigation={data.navigation}
fontCssVars={data.fontCssVars}
themeMode={config.theme.mode}
searchEnabled={config.search.enabled}
siteUrl={config.site}
ogEnabled={config.og.enabled}
page={{ title: config.title, description: config.description }}
>
<section class="mx-auto max-w-3xl px-6 py-24 text-center">
<h1 class="text-3xl font-semibold">Acme Docs</h1>
<p class="mt-4">Guides and reference for the Acme SDK.</p>
<a class="mt-6 inline-block underline" href="/docs">Read the docs</a>
</section>
</PageLayout>A custom page's links are plain HTML, so this one spells out /docs. To serve the docs from the root instead, drop basePath and add one pattern redirect, which also covers /docs itself. The docs' index page becomes your home page:
redirects: [{ from: "/docs/:slug*", to: "/:slug*", status: 308 }],Check every old URL
With npx blume dev running, walk the list you saved and print every 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. For each path it prints, add a redirect, written without the /docs prefix, and run it again until it prints nothing. Run it once more against the preview deployment once it's up. For status codes, patterns, and what each host does with them, see Move documentation URLs while preserving old links.
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, including unknown frontmatter keys. 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 and switch over
If your Fumadocs site runs on Vercel, the Blume site can stay in the same project. Add the vercel() adapter, so redirects are answered with real HTTP status codes:
import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
export default defineConfig({
// ...everything the migration wrote
deployment: vercel(),
});The project uses Vercel's Next.js framework preset, which no longer fits. A preset change in the dashboard applies to every branch, including production builds of your main branch, which is still Fumadocs. Instead, commit a vercel.json beside package.json. It overrides the preset for the deployments that include it, and null selects the Other preset:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": null,
"buildCommand": "npm run build"
}Check that the project's Node.js version is 22 or later, push the branch, and run the URL check against its preview URL. If Vercel Authentication protects your previews, curl gets a login page, so check a sample in the browser and run the full loop after the switch. For other hosts, including a static export, see Deployment.
When the preview looks right, merge. Production builds with Blume at the same domain, and Vercel's Instant Rollback can return it to the last Fumadocs deployment if something breaks.
Troubleshooting
A page's URL lost its leading number
Blume reads a numeric prefix like 01- in a file or folder name as sort order and drops it from the URL, so 01-intro.mdx moves from /docs/01-intro to /docs/intro. Fumadocs keeps the number in the URL. Add a redirect for each one the URL check prints.
An icon is missing
Blume reads kebab-case Lucide names, so a lucide-react name like BookOpen or HomeIcon renders nothing. Navigation icons warn with BLUME_UNKNOWN_ICON, but a card icon fails silently. Strip any Icon suffix, kebab-case the rest, and look the name up on lucide.dev.
The build fails on a page's frontmatter
BLUME_FRONTMATTER_INVALID names the page and key. Replace full: true with mode: wide. A page with _openapi is a generated API page, so delete it. If you extended Fumadocs' page schema with keys of your own, declare them under frontmatter.extend in blume.config.ts, which takes the same Zod schemas.
A meta.ts fails to load
BLUME_META_INVALID means a key from meta.json survived. A meta.ts takes only title, icon, order, collapsed, display, directory, and pages, so remove description, root, defaultOpen, or collapsible.
Math shows dollar signs
Blume renders math in .mdx pages with no plugin to configure, but only between two dollar signs: $$ lines for a block, or $$x^2$$ inside a sentence for inline math. A single $ stays literal text, so rewrite each inline $x$ as $$x$$. The skill treats inline math as unsupported, so also check that the agent didn't move formulas onto lines of their own or drop them.
A redirect fails the build
BLUME_REDIRECT_MATCHES_PAGE means a redirect pattern also covers a page you kept. Narrow it to the URLs that moved.
Next step
Migrate your docs
Run it where your docs' package.json lives, on a clean branch, then work through the review above.
npx blume migrate fumadocs --claudeA step here not working for you? Report a broken step.