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

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 11 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 FumadocsIn Blume
The loader's baseUrl: "/docs"basePath: "/docs" in blume.config.ts
content/docs/docs/, at the same URLs
meta.jsonmeta.ts
Folders with "root": trueHeader tabs in navigation.tabs
"defaultOpen": truecollapsed: 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 BookOpenLucide names like book-open
Inline $x$ math from remark-math$$x$$ inside the sentence; block $$ math works as written
Pages generated by fumadocs-openapiopenapi() in reference, a page per operation
Route handlers for search, llms.txt, Markdown, and OG imagesBuilt 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-blume

Then find where your setup keeps things:

  • lib/source.ts holds the loader. Note its baseUrl (in a scaffolded project, the docsRoute constant in lib/shared.ts) and any i18n option. Newer projects also define the content collection here, with fumadocs-mdx/macro.
  • source.config.ts, in projects that use the Config API, holds mdxOptions and your own remark, rehype, and Shiki plugins.
  • components/mdx.tsx (or mdx-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 -rn

Next, 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.txt

If 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 --claude

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

  1. Writes blume.config.ts, with a title from your package.json name and a basePath from your loader's baseUrl.
  2. Moves the pages into docs/, converts each meta.json to a meta.ts, and turns root folders into header tabs.
  3. Rewrites components and icon names, converts ```npm fences, and removes the imports pages no longer need.
  4. Replaces generated OpenAPI pages with Blume's openapi() reference.
  5. Adds a redirect for every URL that moves.
  6. Points dev, build, and start at blume dev, blume build, and blume preview, removes Fumadocs and Next.js, deletes the app files, then runs blume build and blume validate until 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 the basePath, so the tab opens /docs/sdk. The folder's description is gone, since meta.ts has no such field.
  • display: "group" keeps folders collapsible. Fumadocs folders collapse by default. Blume's default is flat headers, where collapsed does 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 the basePath twice, so links written with it still work.
  • internal-notes is hidden by frontmatter, because Blume lists the pages a meta.ts leaves 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.json name. If your header showed nav.title from lib/layout.shared.tsx instead, set title to it. That file's githubUrl becomes github: { owner, repo }, for the repository and edit links, and its header links become navigation.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 i18n becomes Blume's i18n. Locale folders work as they are, and suffixed files like install.cn.mdx work with parser: "dot". One meta.ts then serves every language, so folder titles from a meta.cn.json don't carry over.
  • API reference. It needs a tab in navigation.tabs pointing 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.txt

Redirects 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 --strict

blume 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 --claude
Read the migration reference

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

Keep going.More guides.

  • Migrate your docs from Starlight

    Move a Starlight site to Blume with a coding agent, keep its pages and URLs, and rebuild the overrides and splash pages that stay Astro work.

  • Migrate your docs from Nextra

    Move a Nextra site's MDX and _meta navigation to Blume with a coding agent, check nested order and React components, and keep every URL working.

  • Migrate your docs from MkDocs Material

    Move an MkDocs Material site's Markdown to Blume, rebuild its nav as folders, rewrite extension syntax, replace its plugins, and check that every old URL still works.

Upgrade your docs with Blume.

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

npx blume init