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

Migrate

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.

By 11 min read

Yes. An MkDocs project is already Markdown in a docs/ folder, which is where Blume looks by default, so plain pages move over unchanged. The work is in what MkDocs adds on top: mkdocs.yml becomes a blume.config.ts plus folders, and Python-Markdown extension syntax like admonitions, content tabs, and snippets becomes Blume directives and components.

Blume has no MkDocs importer: blume migrate has no MkDocs mappings, so this is a partly manual migration. This guide moves a small Material project, before and after, and ends with a script that checks every old URL.

Nothing in Blume replaces mkdocstrings, Jinja template overrides, or macros logic beyond plain variables. Material for MkDocs is in maintenance mode, and its team designed Zensical to build existing MkDocs projects without changes, which is the smaller move if you want to keep your setup.

What carries over

Kept as is

In MkDocs MaterialIn Blume
Markdown pages in docs/The same folder, Blume's default content root
Links to .md files, with anchorsResolved to the page's URL, even after a rename to .mdx
Tables, footnotes, task lists, H~2~O, and a code block's title="send.py"The same syntax
search.exclude and search.boost frontmatterThe same keys
{#id} after a headingThe same in .md; [#id] in .mdx
Images next to pagesThe same relative paths, now optimized at build time

Rewritten

In MkDocs MaterialIn Blume
navFolders, meta.ts, and sidebar.label
The navigation.tabs featurenavigation.tabs
!!! note "Title":::note[Title]
??? note "Title" and ???+<Expandable title="Title">, with defaultOpen
=== "Tab"<Tabs> of <Tab title="Tab">, or <CodeGroup> when every tab is code
--8<-- "file.md"<include> of a file under docs/
linenums="1", hl_lines="2 3"lineNumbers, {2-3}
`#!python print()``print(){:python}`
pymdownx.arithmatex math: $x$ or \(x\) inline, $$ or \[ blocks$$x$$ inside the sentence, and $$ on lines of their own for a block, in .mdx
++ctrl+c++<kbd>Ctrl</kbd>+<kbd>C</kbd>
:material-check: and other icon shortcodes<Icon icon="check" />, with a Lucide name
{ .md-button } linksPlain links, or <Card>s
hide: [navigation, toc]mode: center (hide: [toc] is mode: wide)
tagssearch.tags, a facet on hosted search, with no tag pages
Other files in docs/, like downloadspublic/, at the same URLs, linked from the root
extra_css and extra_javascriptA theme.css file, and script() in analytics

No equivalent

In MkDocs MaterialIn Blume
Definition lists, abbreviations, ==mark==Render as plain text
^^insert^^Renders as superscript
Emoji shortcodes like :smile:Render as text, so paste the emoji itself
Code annotations and inline admonitionsRewrite as prose or a callout
copyrightNo footer text field; override the Footer slot
Template overrides/Layout slots, or blume eject for the whole Astro app

Before you start

Work on a new branch with a clean working tree, so the migration is one diff you can review or throw away:

git switch -c migrate-to-blume

Then save the URLs your site serves today. MkDocs writes a sitemap.xml from your site_url, 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.txt

Replace docs.acme.example with your domain in both places. MkDocs URLs end in a slash and Blume's don't, so the last expression drops it. The sitemap leaves out the pages the redirects plugin writes, so add each redirect_maps key as a URL. In this guide's project, setup.md is /setup:

echo /setup >> ../old-urls.txt

You also need Node.js 22.12 or later, and Claude Code or Codex installed and signed in if an agent drafts the first pass. Keep the Python setup until you switch, so you can still build the old site.

Run the migration

This step is optional: you can do everything below by hand, or let an agent draft it. From the root of your MkDocs project, run:

npx blume migrate --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. There's no mkdocs source to name, so Blume reports that it couldn't detect the framework.

blume migrate converts nothing itself. It opens the agent in your terminal on the blume-migrate skill that ships inside the package. The skill has no MkDocs reference, so the agent inventories the repository and follows the skill's general workflow through blume build and blume validate, with its edits going through its usual permission prompts. Tell it to use the tables on this page, then review its diff and closing summary against the sections below. The skill's general rules turn inline math into display math, so check that too.

The example project

The rest of this guide moves this Material site: an explicit nav with a nested section, tabs, admonitions, content tabs, a snippet, and a redirect.

site_name: Acme Docs
site_url: https://docs.acme.example/
site_description: Send transactional email and SMS with Acme.
repo_url: https://github.com/acme/docs
edit_uri: edit/main/docs/
copyright: Copyright &copy; 2026 Acme

theme:
  name: material
  logo: assets/logo.svg
  palette:
    - media: "(prefers-color-scheme: light)"
      scheme: default
      primary: teal
      toggle:
        icon: material/brightness-7
        name: Switch to dark mode
    - media: "(prefers-color-scheme: dark)"
      scheme: slate
      primary: teal
      toggle:
        icon: material/brightness-4
        name: Switch to light mode
  features:
    - navigation.tabs
    - content.code.copy

nav:
  - Home: index.md
  - Getting started:
      - getting-started/install.md
      - getting-started/quickstart.md
  - Guides:
      - guides/configure.md
      - Deploy: guides/deploy.md
      - Advanced:
          - guides/webhooks.md
  - Reference:
      - reference/cli.md

plugins:
  - search
  - redirects:
      redirect_maps:
        setup.md: getting-started/install.md

markdown_extensions:
  - admonition
  - attr_list
  - md_in_html
  - pymdownx.details
  - pymdownx.highlight
  - pymdownx.inlinehilite
  - pymdownx.snippets
  - pymdownx.superfences:
      custom_fences:
        - name: mermaid
          class: mermaid
          format: !!python/name:pymdownx.superfences.fence_code_format
  - pymdownx.tabbed:
      alternate_style: true
  - pymdownx.emoji:
      emoji_index: !!python/name:material.extensions.emoji.twemoji
      emoji_generator: !!python/name:material.extensions.emoji.to_svg
  - toc:
      permalink: true

extra_css:
  - stylesheets/extra.css

Its files before the migration:

mkdocs.yml
requirements.txt
includes/support.md
docs/
  index.md
  assets/logo.svg
  stylesheets/extra.css
  getting-started/install.md
  getting-started/quickstart.md
  guides/configure.md
  guides/deploy.md
  guides/webhooks.md
  reference/cli.md

And after:

blume.config.ts
package.json
public/logo.svg
docs/
  index.mdx
  _snippets/support.mdx
  getting-started/meta.ts
  getting-started/install.mdx
  getting-started/quickstart.mdx
  guides/configure.md
  guides/deploy.mdx
  guides/advanced/webhooks.md
  reference/cli.md

Write the Blume config

Add a package.json beside mkdocs.yml:

{
  "name": "acme-docs",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "blume dev",
    "build": "blume build"
  }
}

Install Blume, and keep its generated files out of Git:

npm install blume
printf '\nnode_modules/\n.blume/\ndist/\n' >> .gitignore

Then translate mkdocs.yml. Map only what it sets, since every Blume option has a default:

import { defineConfig } from "blume";

export default defineConfig({
  title: "Acme Docs",
  description: "Send transactional email and SMS with Acme.",
  logo: "/logo.svg",
  theme: { accent: "teal" },
  github: { owner: "acme", repo: "docs" },
  deployment: { site: "https://docs.acme.example" },
  navigation: {
    tabs: [
      { label: "Getting started", path: "/getting-started" },
      { label: "Guides", path: "/guides" },
      { label: "Reference", path: "/reference" },
    ],
  },
  redirects: [
    { from: "/setup", to: "/getting-started/install" },
    { from: "/guides/webhooks", to: "/guides/advanced/webhooks" },
  ],
});
  • site_name and site_description become title and description. A docs_dir other than docs becomes content.root.
  • site_url becomes deployment.site. Blume detects it on Vercel, Netlify, and Cloudflare Pages; anywhere else, keep it, or the sitemap and social cards go missing.
  • repo_url and edit_uri become github, for edit links on main.
  • Move the logo from docs/assets/ to public/. A favicon named favicon.png (or .svg, .ico) in public/ is picked up with no config.
  • primary: teal becomes theme.accent, a preset or any CSS color. Blume follows the system color scheme and always shows a toggle. This project's extra.css only set a Material variable, so it goes.
  • content.code.copy and the markdown_extensions features need no config, arithmatex included: math renders in .mdx pages on its own.
  • copyright has nowhere to go. Links in extra.social would move to footer.socials.

Rebuild the navigation

Blume builds the sidebar from folders, not a nav list. Folders become groups, files become pages labeled by their title, and pages sort index first, then by numeric filename prefix, then alphabetically. Handle each place your nav differs.

A section title or order the folder doesn't give you goes in a meta.ts. Without one, getting-started would read "Getting Started":

import { defineMeta } from "blume";

export default defineMeta({
  title: "Getting started",
  pages: ["install", "quickstart"],
});

A title that nav gives a page, like Deploy: guides/deploy.md, becomes sidebar.label. This page also has a Mermaid diagram, so it becomes .mdx:

---
title: Deploy to production
sidebar:
  label: Deploy
---

```mermaid
flowchart LR
  App --> API[Acme API] --> Carrier
```

Set `ACME_API_KEY` on your server before you deploy.

A nested section with no folder, like Advanced, would flatten into Guides. Move its pages into a folder named after it, with a redirect for each moved URL (it's in the config above):

mkdir -p docs/guides/advanced
git mv docs/guides/webhooks.md docs/guides/advanced/webhooks.md

The moved page's link to ../reference/cli.md becomes ../../reference/cli.md. To avoid moving files, an explicit sidebar can nest them in place, but it replaces the whole generated sidebar.

Also check:

  • MkDocs serves a README.md as its folder's index. Blume serves it at /README, so rename it to index.md.
  • MkDocs keeps a numeric prefix like 01- in the URL. Blume reads it as sort order and drops it, so add a redirect, or set a slug that keeps the old path.
  • Blume lists pages you left out of nav. Set sidebar: { hidden: true } on any that should stay out.
  • Each tab in navigation.tabs scopes the sidebar to its folder, like Material's tabs.

Convert the pages

Titles and frontmatter

Move each page's # Heading into a title. Blume renders the title as the page's h1, so a heading left in the body is a second h1.

Blume's frontmatter is strict: a key it doesn't know fails the build. Map hide to mode, tags to search.tags, and status: new to sidebar.badge. Rename a Material icon to a Lucide name, and remove template and other theme keys.

Rename pages that use extensions

Callouts, tabs, diagrams, and components only work in .mdx; in a .md page they stay literal text, and the build stays green. This renames every page that uses admonitions, content tabs, or Mermaid:

for f in $(grep -rlE '^ *(!!!|\?\?\?\+?|===) |^ *```mermaid' docs); do
  git mv "$f" "${f%.md}.mdx"
done

Rename pages with math the same way. Keep the rest as .md, since MDX treats {, <, and HTML comments as syntax. Links that still name install.md resolve to the renamed page.

Rewrite the syntax

Here's the install page before:

# Install Acme

Install the CLI, then sign in.

=== "macOS"

    ```bash
    brew install acme
    ```

=== "Linux"

    ```bash
    curl -fsSL https://acme.example/install.sh | sh
    ```

!!! note
    The installer adds `acme` to your `PATH`.

## Sign in { #sign-in }

```bash title="Terminal"
acme login
```

--8<-- "includes/support.md"

And after. Content tabs become <Tabs>, with blank lines around each code fence. Admonitions become directives. The spaced anchor becomes [#sign-in], and the snippet becomes an include:

---
title: Install Acme
---

Install the CLI, then sign in.

<Tabs>
  <Tab title="macOS">

```bash
brew install acme
```

  </Tab>
  <Tab title="Linux">

```bash
curl -fsSL https://acme.example/install.sh | sh
```

  </Tab>
</Tabs>

:::note
The installer adds `acme` to your `PATH`.
:::

## Sign in [#sign-in]

```bash title="Terminal"
acme login
```

<include>../_snippets/support.mdx</include>

Includes must sit inside the content root, so snippets move from includes/ to docs/_snippets/, where the underscore keeps them from becoming pages. Convert their syntax too:

:::tip[Need help?]
Email support@acme.example.
:::

The quickstart before:

# Quickstart

Send your first message.

```python title="send.py" linenums="1" hl_lines="4"
import acme

client = acme.Client()
client.messages.send(to="+15555550100", body="Hello")
```

??? tip "Use a test number"
    Numbers that start with `+1555555` never leave the sandbox.

!!! warning "Rate limits"
    The sandbox accepts 10 messages a minute. :material-timer-sand:

Next, [configure Acme](../guides/configure.md#environment-variables).

And after. Callouts don't collapse, so the tip loses its color:

---
title: Quickstart
---

Send your first message.

```python title="send.py" lineNumbers {4}
import acme

client = acme.Client()
client.messages.send(to="+15555550100", body="Hello")
```

<Expandable title="Use a test number">
  Numbers that start with `+1555555` never leave the sandbox.
</Expandable>

:::warning[Rate limits]
The sandbox accepts 10 messages a minute. <Icon icon="hourglass" />
:::

Next, [configure Acme](../guides/configure.md#environment-variables).

The home page's buttons become cards, and hide becomes mode: center:

---
title: Acme Docs
mode: center
---

Acme sends transactional email and SMS from one API.

<CardGroup cols={2}>
  <Card title="Install Acme" href="/getting-started/install" icon="download" />
  <Card title="CLI reference" href="/reference/cli" icon="terminal" />
</CardGroup>

Plain Markdown pages like cli.md only trade their heading for a title.

Check for leftovers

This lists any MkDocs syntax left in the content. Rerun it until it prints only what you mean to keep:

grep -rnE '^ *(!!!|\?\?\?\+?|===) |--8<--|\{ *[.#][^}]*\}|<!--|:(material|octicons|fontawesome|simple)-[a-z0-9-]+:' docs

A {#id} it finds in a .md page is fine to keep. A spaced { #id } or {: #id } is not: Blume doesn't read either as an anchor, in any page.

Replace the plugins

Take each entry under plugins in turn:

  • search is built in, so delete it. search.exclude and search.boost keep working.
  • redirects becomes redirects, one per redirect_maps entry, written as URLs.
  • blog posts become pages with type: blog and a flat date (a nested date.created fails the build). Blume generates no index, archive, or category pages, so write a blog/index.mdx, drop categories, and redirect dated URLs like /blog/2024/01/31/hello/.
  • tags becomes search.tags, a facet on hosted search providers, with no tag index pages.
  • social cards are built in as Open Graph images.
  • git-revision-date-localized becomes lastModified: "git". Git committers have no equivalent.
  • macros: plain values from extra move to variables, which use the same {{ name }} syntax (names can't contain dots). Jinja logic, filters, and macro functions have no equivalent.
  • awesome-pages .pages files become meta.ts files, and glightbox goes, since images zoom on click by default.
  • mike versions move to Blume's versioning, and i18n to i18n.
  • mkdocstrings has no replacement. Keep publishing that reference separately and link to it. For an HTTP API, an OpenAPI spec can replace it: see the FastAPI guide.

Check every old URL

Start npx blume dev, then walk the list you saved and print every old URL that doesn't reach 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. Add a redirect for each path it prints, and rerun it until it prints nothing. Here, that's the two redirects already in the config. After you deploy, run it once more with your site's URL in place of http://localhost:4321. 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. Links around the web still carry MkDocs' slash, so check a few on the deployed site: the vercel() adapter redirects them to the page, and a static build serves them from the page's folder.

Build and validate

Stop the dev server, then build the site and check every link in it:

npx blume build
npx blume validate --strict

blume build fails on invalid frontmatter, pages that don't compile, and bad config. blume validate --strict checks every link, anchor, and asset, so a relative link you missed in a moved page shows up as BLUME_BROKEN_LINK. Fix what they report rather than passing --no-strict, which drops the pages that fail.

Deploy and switch over

Many MkDocs sites publish to GitHub Pages with mkdocs gh-deploy, and a Blume site can stay there. Deploy Markdown docs to GitHub Pages has the workflow. A site_url with a path, like a project site, splits into a site and a base:

deployment: { site: "https://acme.github.io", base: "/widget-sdk" },

GitHub Pages serves Blume's redirect pages for exact redirects and ignores patterns. For other hosts, see Redirects.

Your last MkDocs build stays on the gh-pages branch, so rolling back means switching the Pages source back to it. Once the URL check passes against production, delete mkdocs.yml, requirements.txt, and includes/, and submit the new sitemap.xml in Google Search Console.

Troubleshooting

A page fails with "Could not parse expression"

An attribute list is still in an .mdx page: { .md-button }, { #id }, or { width="300" }. MDX reads braces as JavaScript. Remove it, or write an anchor as [#id]. Blume flags an unspaced {#id} as BLUME_MDX_CURLY_ANCHOR before the compile, but the spaced form goes straight to the compiler.

A page fails with "to create a comment in MDX"

An HTML comment is in an .mdx page. Write it as {/* comment */}, or delete it.

The build fails on a page's frontmatter

BLUME_FRONTMATTER_INVALID with a message like Unrecognized keys: "hide", "tags" means a Material key is left. Map it or remove it, as in Convert the pages.

An include fails the build

BLUME_INCLUDE_OUTSIDE_ROOT means the file sits outside docs/. Move it into docs/_snippets/ and update the path.

Links from other sites land at the top of a page

MkDocs numbers a repeated heading example_1 where Blume writes example-1, and strips accents Blume keeps. Pin the old anchor, like ## Example [#example_1].

Next step

Draft the migration

Run it at the root of your MkDocs project, on a clean branch. With no MkDocs mappings, the agent inventories the repo first, so point it at the tables above.

npx blume migrate --claude
Read the migration reference

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

Keep going.More guides.

  • Migrate your docs from Mintlify

    Hand your Mintlify repository to a coding agent, review what it changed, keep your URLs working, and deploy a docs site you host yourself.

  • 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.

  • 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.

Upgrade your docs with Blume.

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

npx blume init