Migrate
Migrate your docs from MkDocs Material
Move a Material for MkDocs site to Blume with a coding agent and a codemod, rebuild its nav as folders, keep its heading anchors, and check that every old URL still works.
By Hayden Bleasel13 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. A coding agent does the conversion, starting with a codemod for the mechanical part, and you review it.
It's written for Material for MkDocs sites, and plain MkDocs, Zensical, and ProperDocs projects migrate the same way. Nothing in Blume replaces mkdocstrings, Jinja template overrides, or macros logic beyond plain variables; the agent lists each one. 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, as long as they name the file as it is now |
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 |
| 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 |
The navigation.sections feature | display: "group" in each second-level section's meta.ts |
copyright | footer.copyright, as plain text |
A page's # Heading | Its title, with any inline code or emphasis removed |
!!! note "Title" | :::note[Title] |
??? note "Title" and ???+ | <Expandable title="Title">, with defaultOpen |
=== "Tab" | <CodeGroup> when every tab is one code block, or <Tabs> of <Tab title="Tab"> |
--8<-- "file.md" | <include> of a file under docs/ |
{ #id } or {#id} after a heading | [#id] |
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 |
A custom script that keeps $ prompts out of copied code | Not needed: the copy button on a console block copies only the commands |
No equivalent
| In MkDocs Material | In Blume |
|---|---|
| Definition lists and abbreviations | Rewrite as a list, or a <Tooltip> where it matters |
Emoji shortcodes like :smile: | Render as text, so paste the emoji itself |
| Code annotations and inline admonitions | Rewrite as prose or a callout |
| Snippet line ranges and sections | Move the excerpt into its own file |
A link inside copyright | Kept as text; add the link to footer.links |
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.txtKeep the Python setup until you switch: the agent builds the old site once more to compare heading anchors. You also need Node.js 22.19 or later, and Claude Code or Codex installed and signed in.
Run the migration
From the folder that holds mkdocs.yml, run:
npx blume migrate mkdocs --claudeTo use Codex, swap --claude for --codex. On pnpm 12, run it with pnpm dlx --allow-build=esbuild instead of npx. Leave out mkdocs and Blume detects the source from mkdocs.yml, mkdocs.yaml, zensical.toml, or properdocs.yml.
blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its MkDocs reference. Following it, the agent:
- Runs the bundled MkDocs codemod over
docs/. It converts admonitions, content tabs, snippets, code block options, heading ids, icons, and keys, moves each page's H1 into itstitle, renames only the pages that now need MDX to.mdx, points links at the renamed files, and reports everything it leaves. - Works through that report: dropped buttons and image sizes, unknown icons, mkdocstrings and macros, and anything MDX would reject.
- Writes
blume.config.tsfrommkdocs.yml: title, logo, colors, edit links, social links, analytics, header tabs, and redirects. - Rebuilds
navas folders andmeta.tsfiles. - Pins the old heading ids with the bundled
pin-heading-ids.mjs, comparing your MkDocs build with Blume's. - Adds a
package.json, updates CI and any repository files that named the renamed pages, and runsblume buildandblume validate --strictuntil they pass.
Its report has one block per page. Part of it, for the example project below:
docs/getting-started/install.md → docs/getting-started/install.mdx
tabs: 1
admonition: 1
snippet: 1
heading-id: 1
title: 1
rename: 1
! snippet-copied: includes/support.md is outside the content root → copied to _snippets/includes/support.md (keep it in sync with a predev/prebuild script)
docs/index.md
attr-list: 1
title: 1
sidebar-label: 1
hide: 1
link: 1
! attr-dropped: button '{ .md-button .md-button--primary }' dropped
! attr-dropped: button '{ .md-button }' dropped
Needs a human (site-level):
- plugin search: built in — delete
- plugin redirects: map redirect_maps to redirects (Markdown paths → routes)
- extra_css — port what still applies to a project-root theme.cssThe 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/includes/support.md
getting-started/meta.ts
getting-started/install.mdx
getting-started/quickstart.mdx
guides/meta.ts
guides/configure.md
guides/deploy.mdx
guides/(advanced)/meta.ts
guides/(advanced)/webhooks.md
reference/cli.mdWrite the Blume config
Map only what mkdocs.yml 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" },
footer: { copyright: "Copyright © 2026 Acme" },
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" }],
});site_nameandsite_descriptionbecometitleanddescription. Adocs_dirother thandocsbecomescontent.root.site_urlbecomesdeployment.site. Blume detects it on Vercel and Netlify; anywhere else, keep it, or the sitemap and social cards go missing. A path in it, like/uv/, becomesdeployment.base.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.copyrightbecomesfooter.copyright, which is plain text, so©turns into©. Links inextra.socialwould move tofooter.socials.
If mkdocs.yml sits at the root of a Python or Rust repository, put blume.config.ts and package.json in docs/ instead, with content.root: ".". blume build empties the project's dist/, which is where Python and Rust tools write their packages.
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. The codemod already turned each nav title that differs from a page's heading, like Deploy: guides/deploy.md, into sidebar.label.
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 nested section with no folder, like Advanced, would flatten into Guides. Move its pages into a group folder, a folder name in parentheses: the sidebar nests them under "Advanced", and their URLs don't change, so they need no redirects:
---
title: Webhooks
---
Acme posts delivery events to your endpoint. See the [CLI reference](../../reference/cli.md).The moved page sits a folder deeper, so its link to ../reference/cli.md becomes ../../reference/cli.md, and links to it should name its new path. Material renders second-level sections as collapsible, so give the folder display: "group":
import { defineMeta } from "blume";
export default defineMeta({
display: "group",
});A section that nav places under another folder, like uv's pip/ pages listed under Concepts, can't use a group folder: moving the files changes their URL segment. Move them anyway and set each page's slug to its old URL.
Also check:
- A folder's
indexpage links its group's header and also lists as the group's first row. To show it once, as Material does, setsidebar: { hidden: true }on the index: only the row goes, and the page stays insitemap.xmland search. - 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
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 the codemod. Content tabs that each hold one code block become a <CodeGroup>, the admonition becomes a directive, the heading's id is pinned, and the snippet becomes an include. Includes must sit inside docs/, so the codemod copied includes/support.md to docs/_snippets/, where the underscore keeps it from becoming a page, and converted its admonition too:
---
title: Install Acme
---
Install the CLI, then sign in.
<CodeGroup>
```bash title="macOS"
brew install acme
```
```bash title="Linux"
curl -fsSL https://acme.example/install.sh | sh
```
</CodeGroup>
:::note
The installer adds `acme` to your `PATH`.
:::
## Sign in [#sign-in]
```bash title="Terminal"
acme login
```
<include>/_snippets/includes/support.md</include>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" {4} lineNumbers
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 codemod reports what it can't decide. Here, it dropped the home page's { .md-button } attributes, so the buttons become cards by hand, and hide became mode: center:
---
title: Acme Docs
sidebar:
label: Home
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>Pages with no extension syntax, like cli.md, stay .md and only trade their heading for a title: MDX treats {, <, and HTML comments as syntax, so the codemod renames only the pages that need it.
To list any MkDocs syntax left in the content, run this until it prints only what you mean to keep:
grep -rnE '^\s*(!!!|\?\?\?\+?|===[+!]*) |--8<--|\{ *[.#:][^}]*\}|\{\{|\{%|<!--|:(material|octicons|fontawesome|simple)-[a-z0-9-]+:' docsReplace 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. - llmstxt is built in:
/llms.txtand a.mdcopy of every page. Redirect the plugin's/page/index.mdURLs to/page.md. - 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.
Keep heading links working
MkDocs and Blume turn headings into ids differently: MkDocs numbers a repeated heading example_1 where Blume writes example-1, strips accents Blume keeps, and collapses punctuation Blume doesn't. Links from other sites to those headings land at the top of the page. After a blume build, the agent builds the old site and runs the bundled script, which pins the old id on each heading that changed:
node node_modules/blume/skills/blume-migrate/scripts/pin-heading-ids.mjs --old ../old-site --writeIt rebuilds and runs it again until it reports nothing left to pin. A pinned heading reads ## Basic usage [#basic-usage_1].
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 route; do
code=$(curl -sL -o /dev/null -w "%{http_code}" "http://localhost:4321$route")
[ "$code" = "200" ] || echo "$code $route"
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, the only one is /setup, already in the config: the moved webhooks page kept its URL in its group folder. 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.
Links around the web still carry MkDocs' slash. blume dev and blume preview redirect a slashed URL to the page, as the vercel() adapter does, and a static build serves it from the page's folder on other hosts, so the list doesn't need both spellings.
Update the rest of the repository
- Search the repository for the paths of pages that became
.mdx: release tooling that bumps versions inside pages, bot configs, CI path filters, and ignore files often name them. - If you format with Prettier, extend any
*.mdoverrides in.prettierrcand.editorconfigto*.mdxbefore you reformat:
{
"overrides": [
{
"files": ["*.md", "*.mdx"],
"options": { "proseWrap": "always" }
}
]
}- Replace the
mkdocs buildormkdocs gh-deploystep in CI with Node.js, an install,npx blume build, andnpx blume validate --strict. - Remove the Python docs dependencies once you've switched, and regenerate the Python lockfile in the same change.
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 or a placeholder is still in an .mdx page: { .md-button }, { width="300" }, or literal {name} text. MDX reads braces as JavaScript. Remove it, or escape the brace as \{. Some, like { width="300" }, compile and then fail while the page renders, which the build reports as BLUME_MDX_UNDEFINED_NAME at the line. blume check reports the others ahead of the build, with the file and line: BLUME_MDX_SYNTAX, and BLUME_MDX_ATTRIBUTE_LIST for an attribute list.
A page fails with "Expected a closing tag"
A placeholder in prose, like uv run <command>, reads as an HTML tag in MDX, and blume check reports it as BLUME_MDX_SYNTAX at its line. Put it in backticks.
Prettier turned a comment into a broken one
Prettier rewrites a multi-line {/* … */} comment into {/_ … _/}. Keep MDX comments on one line, as the codemod writes them.
The build fails on a page's frontmatter
BLUME_FRONTMATTER_INVALID with a message like Unrecognized key: "categories" means a Material key is left that the codemod reported. Map it or remove it.
An include fails the build
BLUME_INCLUDE_OUTSIDE_ROOT means the file sits outside docs/. Move it into docs/_snippets/ and update the path. If the original keeps changing, copy it there in a prebuild script and ignore the copy in Git.
Next step
Migrate your docs
Run it in the folder that holds mkdocs.yml, on a clean branch, then check its work against the sections above.
npx blume migrate mkdocs --claudeA step here not working for you? Report a broken step.