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 Hayden Bleasel11 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 Material | In Blume |
|---|---|
Markdown pages in docs/ | The same folder, Blume's default content root |
Links to .md files, with anchors | Resolved 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 frontmatter | The same keys |
{#id} after a heading | The same in .md; [#id] in .mdx |
| Images next to pages | The same relative paths, now optimized at build time |
Rewritten
| In MkDocs Material | In Blume |
|---|---|
nav | Folders, meta.ts, and sidebar.label |
The navigation.tabs feature | navigation.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 } links | Plain links, or <Card>s |
hide: [navigation, toc] | mode: center (hide: [toc] is mode: wide) |
tags | search.tags, a facet on hosted search, with no tag pages |
Other files in docs/, like downloads | public/, at the same URLs, linked from the root |
extra_css and extra_javascript | A theme.css file, and script() in analytics |
No equivalent
| In MkDocs Material | In 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 admonitions | Rewrite as prose or a callout |
copyright | No 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-blumeThen 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.txtReplace 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.txtYou 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 --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. 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 © 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.cssIts 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.mdAnd 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.mdWrite 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' >> .gitignoreThen 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_nameandsite_descriptionbecometitleanddescription. Adocs_dirother thandocsbecomescontent.root.site_urlbecomesdeployment.site. Blume detects it on Vercel, Netlify, and Cloudflare Pages; anywhere else, keep it, or the sitemap and social cards go missing.repo_urlandedit_uribecomegithub, for edit links onmain.- Move the logo from
docs/assets/topublic/. A favicon namedfavicon.png(or.svg,.ico) inpublic/is picked up with no config. primary: tealbecomestheme.accent, a preset or any CSS color. Blume follows the system color scheme and always shows a toggle. This project'sextra.cssonly set a Material variable, so it goes.content.code.copyand themarkdown_extensionsfeatures need no config,arithmatexincluded: math renders in.mdxpages on its own.copyrighthas nowhere to go. Links inextra.socialwould move tofooter.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.mdThe 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.mdas its folder's index. Blume serves it at/README, so rename it toindex.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 aslugthat keeps the old path. - Blume lists pages you left out of
nav. Setsidebar: { hidden: true }on any that should stay out. - Each tab in
navigation.tabsscopes 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"
doneRename 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-]+:' docsA {#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.excludeandsearch.boostkeep working. - redirects becomes
redirects, one perredirect_mapsentry, written as URLs. - blog posts become pages with
type: blogand a flatdate(a nesteddate.createdfails the build). Blume generates no index, archive, or category pages, so write ablog/index.mdx, dropcategories, 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
extramove tovariables, which use the same{{ name }}syntax (names can't contain dots). Jinja logic, filters, and macro functions have no equivalent. - awesome-pages
.pagesfiles becomemeta.tsfiles, 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.txtRedirects 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 --strictblume 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 --claudeA step here not working for you? Report a broken step.