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

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 13 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 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, 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 frontmatterThe same keys
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
The navigation.sections featuredisplay: "group" in each second-level section's meta.ts
copyrightfooter.copyright, as plain text
A page's # HeadingIts 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 } 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
A custom script that keeps $ prompts out of copied codeNot needed: the copy button on a console block copies only the commands

No equivalent

In MkDocs MaterialIn Blume
Definition lists and abbreviationsRewrite 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 admonitionsRewrite as prose or a callout
Snippet line ranges and sectionsMove the excerpt into its own file
A link inside copyrightKept 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-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

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

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

  1. 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 its title, renames only the pages that now need MDX to .mdx, points links at the renamed files, and reports everything it leaves.
  2. Works through that report: dropped buttons and image sizes, unknown icons, mkdocstrings and macros, and anything MDX would reject.
  3. Writes blume.config.ts from mkdocs.yml: title, logo, colors, edit links, social links, analytics, header tabs, and redirects.
  4. Rebuilds nav as folders and meta.ts files.
  5. Pins the old heading ids with the bundled pin-heading-ids.mjs, comparing your MkDocs build with Blume's.
  6. Adds a package.json, updates CI and any repository files that named the renamed pages, and runs blume build and blume validate --strict until 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.css

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/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.md

Write 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_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 and Netlify; anywhere else, keep it, or the sitemap and social cards go missing. A path in it, like /uv/, becomes deployment.base.
  • 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.
  • copyright becomes footer.copyright, which is plain text, so &copy; turns into ©. Links in extra.social would move to footer.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 index page links its group's header and also lists as the group's first row. To show it once, as Material does, set sidebar: { hidden: true } on the index: only the row goes, and the page stays in sitemap.xml and 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 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

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-]+:' docs

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.
  • llmstxt is built in: /llms.txt and a .md copy of every page. Redirect the plugin's /page/index.md URLs to /page.md.
  • 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.

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 --write

It 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.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, 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 *.md overrides in .prettierrc and .editorconfig to *.mdx before you reformat:
{
  "overrides": [
    {
      "files": ["*.md", "*.mdx"],
      "options": { "proseWrap": "always" }
    }
  ]
}
  • Replace the mkdocs build or mkdocs gh-deploy step in CI with Node.js, an install, npx blume build, and npx 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 --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 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 --claude
Read the migration reference

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

Keep going.More guides.

  • Migrate your docs from mdBook

    Hand your mdBook to a coding agent, rebuild SUMMARY.md as folders that keep each chapter's path, redirect every old .html page, and keep included code generated from your source on every build.

  • Migrate your docs from GitBook

    Hand your Git-synced GitBook repository to a coding agent, keep every page at the URL it has today, convert GitBook's blocks to components, and deploy docs you host yourself.

  • Migrate your docs from ReadMe

    Hand your Git-synced ReadMe repository to a coding agent, keep every page at its flat URL, generate the API reference from your specs, and deploy docs you host yourself.

Upgrade your docs with Blume.

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

npx blume init