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

Migrate

Migrate your docs from Docus

Hand your Docus site to a coding agent, convert its MDC components with a codemod, turn sections into tabs without moving a URL, and keep your assistant, MCP server, redirects, and heading anchors.

By 11 min read

By the end of this guide, your Docus docs are a Blume project: your MDC components converted to Blume's callouts and components, your.navigation.yml files turned into folder settings, your sections into header tabs, and every old URL still reaching its page. A coding agent does the conversion, starting with a codemod for the mechanical part, and you review it.

It's written for Docus 3 and later, the docus package on Nuxt Content and Nuxt UI. Docus is the Nuxt docs theme, not Docusaurus, which has its own guide. Older @nuxt-themes/docus sites migrate too, with more of the work left to the agent.

What carries over

The run behind this guide migrated a real Docus site: 51 pages with eight sections, nearly 200 MDC components, an AI assistant, and an MCP server. Every page carried over, and every old URL reached a page. Here's how the pieces map:

In DocusIn Blume
app.config.ts and nuxt.config.tsblume.config.ts
content/1.getting-started/2.installation.mdThe same file, at the same URL, as .mdx when it uses components
.navigation.yml in a foldermeta.ts in the same folder
Sub-navigation sectionsHeader tabs
::note, ::tip, ::warning, ::caution, ::callout:::note, :::tip, :::warning, :::danger, or <Callout> with its icon
::card-group, ::steps, ::tabs, ::accordion<CardGroup>, <Steps>, <Tabs>, <Accordion>
::code-group<CodeGroup>, or one package-install block for an install command
::field, ::prompt, :kbd, :icon<ResponseField>, <Prompt>, <kbd>, <Icon />
```ts [nuxt.config.ts]```ts nuxt.config.ts
i-lucide-download iconsdownload
links buttons in a page's headerRelated-page cards at its foot
routeRules redirectsredirects in blume.config.ts
The AI assistant and MCP serverBlume's assistant and MCP server, on server output
content/index.md landing pageA full-width index.mdx page you rebuild from cards and links

Here's one page before and after the codemod:

---
title: Install Acme
description: Add the Acme SDK to your project.
navigation:
  title: Installation
  icon: i-lucide-download
links:
  - label: Configuration
    icon: i-lucide-settings
    to: /getting-started/configuration
---

::tip
You need Node.js 22 or later.
::

::code-group
```bash [pnpm]
pnpm add @acme/sdk
```
```bash [npm]
npm install @acme/sdk
```
::

::callout{icon="i-lucide-key-round" color="warning"}
Keep your API key out of the browser.
::

```ts [acme.config.ts]
export default defineAcmeConfig({ region: "eu" })
```

::card-group
  :::card{icon="i-lucide-settings" title="Configuration" to="/getting-started/configuration"}
  Set the region and retries.
  :::
::
---
title: Install Acme
description: Add the Acme SDK to your project.
sidebar:
  label: Installation
  icon: download
related:
  - "Configuration": /getting-started/configuration
---

:::tip
You need Node.js 22 or later.
:::

```package-install
npm install @acme/sdk
```

<Callout type="warning" icon="key-round">

Keep your API key out of the browser.

</Callout>

```ts acme.config.ts
export default defineAcmeConfig({ region: "eu" })
```

<CardGroup>

<Card title="Configuration" href="/getting-started/configuration" icon="settings">

Set the region and retries.

</Card>

</CardGroup>

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 save your old site's URLs. Docus publishes a sitemap, so from the folder whose package.json depends on docus, list it, with your own domain:

curl -s -A 'Mozilla/5.0' https://docs.acme.example/sitemap.xml \
  | grep -o '<loc>[^<]*' \
  | sed -e 's#<loc>https://docs.acme.example##' -e 's#^$#/#' \
  > ../old-urls.txt

Add each redirect source from routeRules in nuxt.config.ts to the list. The browser user agent matters: some Docus deployments answer curl with Markdown.

A local build is better still: it also records your heading anchors and what each page rendered. With your dependencies installed, generate the site and copy it outside the folders the agent will delete (add --extends docus if your scripts pass it):

npx nuxt generate
cp -R .output/public ../old-site

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

To use Codex, swap --claude for --codex. Leave out docus and Blume detects the source from a docus dependency in that folder's package.json.

blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its Docus reference. Following it, the agent:

  1. Runs the bundled Docus codemod over content/. It converts the Nuxt UI components, fence labels, icons, frontmatter, and .navigation.yml files, renames the pages that now need MDX, and reports everything it leaves.
  2. Works through that report: your own components, the landing page, icons outside Lucide, and frontmatter keys Blume rejects.
  3. Writes blume.config.ts from app.config.ts and nuxt.config.ts: title, logo, edit links, social links, analytics, a header tab per section, and your redirects.
  4. Pins each heading's old anchor, using your old build.
  5. Swaps Docus and Nuxt for Blume in package.json, deletes the Nuxt files, 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 file:

1.getting-started/2.installation.md → 1.getting-started/2.installation.mdx
  1 × callout → :::tip
  1 × callout → <Callout> with its icon
  1 × card → <Card>
  1 × card-group → <CardGroup>
  1 × code-group → package-install fence
  1 × fence [label] → title
  1 × links → related
  1 × navigation.icon → sidebar.icon
  1 × navigation.title → sidebar.label

2.guides/1.playground.md
  1 × navigation.icon → sidebar.icon
  REVIEW line 12: `::acme-playground` isn't a Nuxt UI prose component: read its SFC (app/components/content/ or a module) and convert it by hand. It's left as written

index.md
  REVIEW line 8: `::u-page-hero` is a Nuxt UI page component: rebuild it by hand (references/docus.md, Landing page). It's left as written

It ends with totals and the number of items left to review. The agent works through them, and its own report lists what it migrated, dropped, and approximated. Keep both for the review.

MDC to MDX

Docus pages are written in MDC, Nuxt Content's Markdown with components: a block opens with ::name and closes with a line of colons, and inline components look like :kbd{value="K"}. Blume pages are Markdown or MDX, and components need MDX. Blume reads MDC as plain text, so an unconverted ::note shows on the page as ::note. The build doesn't fail, but it warns about each one as BLUME_MDC_SYNTAX, with its file and line.

The conversion follows three rules:

  • Callouts become directives. ::note becomes :::note, with three colons. Docus's red ::caution becomes :::danger, since Blume reads :::caution as a warning.
  • Other components become JSX. Cards, steps, tabs, and the rest become Blume components with the same content, and their props move to attributes.
  • Prose gets MDX-safe. MDX reads { as the start of code and < as the start of a tag, so text like { data }, useTool<Input>(), or <50 ms stops the build. The codemod escapes them in every page it turns into MDX.

Prompt bodies are where those hazards gather:

::prompt
---
description: Add a tool
---
Call defineTool({ name }) and useTool<Input>() in under <50 ms.
::
<Prompt description="Add a tool">

Call defineTool(\{ name \}) and useTool&lt;Input>() in under &lt;50 ms.

</Prompt>

MDC pairs blocks by exact colon count: a :: line closes the nearest block opened with two colons, and everything still open inside it. The codemod pairs them the same way and reports blocks that were never closed, which MDC ran to the end of the page, and closers that matched nothing, which the live site showed as text. Pages with no components stay .md, and MDC examples inside code blocks stay as written.

Keep the AI assistant and MCP server

Docus turns on its assistant when the build has an AI Gateway key, and serves an MCP server at /mcp. Blume has both, but they run on a server, so the site switches from static files to server output. If your live site shows the assistant, the agent keeps it with the same model:

import { defineConfig } from "blume";
import { gateway } from "blume/ai";
import { vercel } from "blume/deploy";

export default defineConfig({
  title: "Acme",
  content: { root: "content" },
  ai: {
    assistant: {
      enabled: true,
      provider: gateway({ model: "google/gemini-3-flash" }),
      suggestions: [{ label: "How do I install the SDK?" }],
    },
  },
  agents: {
    mcp: { enabled: true, name: "Acme" },
    skills: "./skills",
  },
  deployment: vercel(),
});

deployment: vercel() builds for Vercel; Blume also has node(), netlify(), and cloudflare(). The assistant reads AI_GATEWAY_API_KEY, or Vercel's own token when it's deployed there. Blume's MCP server has a fixed set of tools for searching and reading your docs, so tools and prompts your site added in server/mcp/ don't carry over. Your skills/ folder does, published under /.well-known/agent-skills/. See Assistant and MCP server.

blume preview can't serve a vercel() build, so try the assistant and /mcp in npx blume dev, then on a preview deployment.

Review the changes

Start with git diff --stat, run npx blume dev, and work through these, which are where a Docus migration most often needs a second look.

  • Your own components. Components in app/components/content/, and ones a Nuxt module provides, stay as written for the agent to rebuild as Markdown, a Blume component, or a Vue island. Compare each with your live site: a local build can miss a module's component that production renders.
  • The landing page. Nuxt UI's page sections become a full-width page of headings, links, and cards. Animations and decorative backgrounds don't carry over.
  • Callouts that were links. In Docus a whole callout can link somewhere. In Blume the link goes in its text.
  • Card descriptions. Nuxt UI's prose card shows no description, so text you put there never appeared on your site. The codemod moves it into the card's body and flags it: keep it or delete it.
  • Icons. Lucide icons carry over by name. Brand icons like i-simple-icons-github don't, so the agent picks a Lucide icon or saves the SVG. Lucide's x is a close icon, not the X logo.
  • Navigation. Click through each tab. Docus sorted 10.faq.md before 2.setup.md; Blume sorts by the number, so long sections may reorder.
  • The theme. Docus is green by default. Blume isn't, so set theme.accent if the green was your brand.
  • Pages about the old site. Look for prose that describes dropped features: MCP prompts your server no longer offers, Nuxt Studio, or Docus itself.
  • The repository. Nuxt's tsconfig.json and its postinstall: nuxt prepare script are gone, and .gitignore lists .blume and dist. In a pnpm workspace with a release-age guard, blume is excluded from it, and the lockfile is regenerated.

Run from the folder that holds blume.config.ts, these print MDC blocks and inline components left in your pages, and Iconify icon names. Matches inside code blocks are examples, not leftovers:

grep -rnE '^[[:space:]]*:{2,}[a-z]' content --include='*.md' --include='*.mdx' \
  | grep -vE ':{3,}(note|tip|warning|danger|info|success)$'
grep -rnE '(^|[^[:alnum:]_:/]):[a-z][a-z-]*(\[[^]]*\])?\\?\{' content --include='*.mdx'
grep -rnE '"i-[a-z]+-|: i-[a-z]+-' content --include='*.md' --include='*.mdx'

Check every old URL

Docus and Blume both drop the number prefixes from file names, so your pages keep their URLs. What moves are your redirects and Docus's Markdown copies of each page, which it served at /raw/… and Blume serves at the page's URL plus .md. The agent ports routeRules like this:

routeRules: {
  '/guide': { redirect: { to: '/guide/overview', statusCode: 301 } },
  '/old-page': { redirect: '/new-page' },
},
redirects: [
  { from: "/guide", to: "/guide/overview" },
  { from: "/old-page", to: "/new-page", status: 307 },
  { from: "/raw/:path*", to: "/:path*" },
],

A string redirect in routeRules is temporary (307), so it keeps that status. To check every old URL, start npx blume dev and save this beside blume.config.ts:

import { readFileSync } from "node:fs";

const [list, origin = "http://localhost:4321"] = process.argv.slice(2);
const urls = readFileSync(list, "utf8").split("\n").filter(Boolean);

let missing = 0;
for (const url of urls) {
  // fetch follows redirects, so a moved page passes when its target loads.
  const response = await fetch(new URL(url, origin));
  if (!response.ok) {
    console.log(`${response.status} ${url}`);
    missing += 1;
  }
}
console.log(
  missing === 0
    ? `All ${urls.length} old URLs reach a page.`
    : `${missing} of ${urls.length} old URLs don't.`
);

Run it with your URL list while the dev server is up:

node check-urls.mjs ../old-urls.txt

It follows each redirect and prints every URL that doesn't end on a page. Add a redirect for each one and run it again until it reports that every old URL reaches a page.

Check the heading anchors

Docus builds heading ids almost like Blume, but collapses repeated dashes and prefixes a leading digit with an underscore: ## 1. Install is #_1-install in Docus and #1-install in Blume. 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

Server output keeps its pages in dist/client, and the script reads them there by itself. 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. 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 Docus sites deploy to Vercel. With vercel(), the build writes Vercel's .vercel/output folder rather than dist/, so the project's vercel.json sets no output directory. In a workspace, it installs from the root:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "framework": null,
  "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
  "buildCommand": "pnpm run build"
}

"framework": null overrides the Nuxt preset your project was created with. In the Vercel dashboard, keep the Root Directory on your docs package and set Node.js to 22.19 or later. With Turborepo, add ".vercel/output/**" to the docs' build outputs. The monorepo guide covers the rest of a workspace setup, and Deployment covers other hosts.

Before switching your domain, deploy a preview and open a few old URLs, an old /raw/… address, and /mcp on it. Keep the old deployment until the new one passes, so rolling back is a promotion away. Then submit the new sitemap.xml in Google Search Console.

What doesn't carry over

  • The Nuxt app. Pages in app/pages/, server routes, layout overrides, and Nuxt modules. Your own content components become Blume components, Markdown, or islands.
  • Some components. The file browser of ::code-tree (its files become tabs), the live rendering of ::code-preview (now Preview and Code tabs), and the Windsurf and Claude buttons on prompts.
  • Site settings. A custom title template, Nuxt UI's theme overrides, a forced color mode (Blume always shows the toggle), Nuxt Studio, and Vercel Speed Insights.
  • Your MCP server's own tools and prompts. Blume's server offers search and page reading only.

Troubleshooting

A page shows ::note as text

The block wasn't converted, and the build log names it as BLUME_MDC_SYNTAX. Rewrite it as :::note with three colons, and make sure the page is .mdx.

The build fails with "Could not parse expression"

The page has a { or a [text]{.class} span in its prose. blume check reports it as BLUME_MDX_SYNTAX at its line. Escape the brace as \{, or drop the span's attributes.

The build fails with BLUME_TSCONFIG_EXTENDS

Nuxt's tsconfig.json points into .nuxt/, which no longer exists. Delete the file, or replace it.

The anchor script pairs no headings

It warns when no page turned up in both builds. Point --old at the folder nuxt generate wrote, with one .html file per page, and run npx blume build first. For pages outside dist or dist/client, pass the folder with --dist.

Next step

Migrate your docs

Run it in the folder whose package.json depends on docus, on a clean branch, after saving your old site's URLs. Then work through the review above.

npx blume migrate docus --claude
Read the migration reference

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

Keep going.More guides.

  • Migrate your docs from Docsify

    Hand your Docsify site to a coding agent, convert its callouts, tabs, and includes with a codemod, rebuild its sidebar as folders, and keep every old #/ link and heading anchor working.

  • Migrate your docs from Just the Docs

    Hand your Just the Docs site to a coding agent, convert its Kramdown callouts, includes, and Liquid with a codemod, rebuild its front-matter sidebar as folders, and keep every old URL, redirect, and heading anchor working.

  • Migrate your docs from a GitHub wiki

    Hand your GitHub wiki to a coding agent, convert its wiki links, alerts, and images with a codemod, rebuild _Sidebar.md as folders, and prepare a link stub for every old wiki page, ready for you to push.

Upgrade your docs with Blume.

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

npx blume init