Migrate
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.
By Hayden Bleasel8 min read

By the end of this guide, your ReadMe developer hub is a Blume project in the same repository: your guides and changelog converted from ReadMe's Markdown to Blume's, your categories rebuilt as folders, your API reference generated from the OpenAPI files you already sync, and every old URL either still serving its page or redirecting to it. A coding agent does the conversion with a codemod that ships with Blume, and you review it.
Which repository you have
ReadMe is hosted, so what's in Git depends on how your project talks to it:
- Bi-directional Git sync. ReadMe commits every page to your repository: folders per category,
_order.yamlfiles for the sidebar order, your OpenAPI files inreference/, and one branch per version. This is the shape the codemod reads, and the one this guide follows. - Uploads with
rdme. A CI step pushes Markdown files to ReadMe, and each file's frontmatter places it in the sidebar. The repository holds only what CI uploads, so edits made in ReadMe's editor are missing. The agent rebuilds the folders from frontmatter first, then converts the pages. - No Git at all. Export first. On a current project, connect bi-directional sync to a new, empty repository (Settings → Git Connection): ReadMe writes the whole hub there, every version as a branch, and you're in the first case.
What carries over
Your pages are MDX already, so most of the work is ReadMe's own syntax and keeping its flat URLs. On one real hub with 75 guides and 10 API definitions, the build passed and all 247 old URLs either served a page or redirected to one.
| In ReadMe | In Blume |
|---|---|
| Dashboard settings: logo, colors, fonts, navigation names | blume.config.ts, read from the settings your hub publishes |
Categories and _order.yaml | Group folders, which add nothing to the URL, with a meta.ts for order |
| Pages nested under a parent page | Folders, with each page's flat URL pinned in its frontmatter |
Emoji blockquotes and <Callout> | :::info, :::warning, and the other callouts |
| Code blocks written back to back | <CodeGroup> tabs |
<Image>, <Embed>, <Table>, <Cards>, <Accordion> | Markdown images, <YouTube>, Markdown tables, <CardGroup>, <Accordion> and <Expandable> |
| Glossary terms | <Tooltip>, with each definition from your hub |
| Reusable content blocks | Partials in _snippets/, pulled in with <include> |
| Custom components | Blume components, or React islands when they're interactive |
| Endpoint pages, one per operation | Pages generated from your OpenAPI files, in ReadMe's order, with any prose you wrote on them |
The changelog, from your main branch | Changelog pages with the same URLs, an index, and an RSS feed |
| Font Awesome icons | Lucide icons |
Here's one page before and after the conversion:
---
title: Create an order
excerpt: Place your first order with the Orders API.
metadata:
title: Create an order | Acme
icon: fa-cart-shopping
---
# Create an order
> 📘 Sandbox keys
>
> Test keys never charge a card. See [API keys](doc:api-keys).
<Image align="center" src="https://files.readme.io/1a2b3c4-order-flow.png" />
```curl
curl https://api.acme.example/v1/orders
```
```javascript Node SDK
await acme.orders.create({ sku: "tee" });
```---
title: Create an order
description: Place your first order with the Orders API.
icon: shopping-cart
seo:
title: Create an order | Acme
---
:::info[Sandbox keys]
Test keys never charge a card. See [API keys](/docs/api-keys).
:::

<CodeGroup>
```bash cURL
curl https://api.acme.example/v1/orders
```
```javascript title="Node SDK"
await acme.orders.create({ sku: "tee" });
```
</CodeGroup>The page still serves /docs/create-an-order. Every page becomes .mdx, since ReadMe's .md files are MDX.
Before you start
Check out the branch named after your hub's default version (not main, which holds the changelog) and branch from it. Use a name that matches none of your ReadMe versions: with sync on, ReadMe reads and writes every branch named after a version, so restructuring one changes your live hub.
git switch -c migrate-to-blumeReadMe keeps the changelog only on main. Bring it into your working tree:
git archive origin/main changelogs | tar -xThen save every URL your hub serves today, outside the repository:
curl -s https://docs.acme.example/sitemap.xml \
| grep -o '<loc>[^<]*' \
| sed -e 's#<loc>https://docs.acme.example##' \
> ../old-urls.txtAnd save one page of the hub as hub.html at the root of the repository. Every page embeds your project's settings (logo, colors, fonts, navigation names, glossary, and ReadMe's own redirects), so the agent reads them from that file instead of asking you, and deletes it when it's done:
curl -s https://docs.acme.example/docs/getting-started -o hub.htmlReplace docs.acme.example with your hub's domain. ReadMe can start refusing requests (429) after as few as eight in a few minutes, so save these files rather than fetching pages one by one. Check that you have Node.js 22.19 or later, and that Claude Code or Codex is installed and signed in.
Run the migration
From the root of your repository, run:
npx blume migrate readme --claudeTo use Codex, swap --claude for --codex. Leave out readme and Blume detects a synced repository from its _order.yaml files. An rdme upload repository leaves no such sign, so name the source there.
blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its ReadMe reference. Following it, the agent:
- Runs the ReadMe codemod, which moves every page into place, converts ReadMe's syntax and frontmatter, turns
_order.yamlintometa.ts, and writes the redirects it can already compute. It lists everything that needs a person. - Writes
blume.config.tsfrom your hub's settings, with each OpenAPI file as a source of theopenapi()reference. - Works through the codemod's list: custom components, interactive widgets, page styles, links that were already broken, and images.
- Builds once, reads the new operation routes from the build, and runs the codemod's second pass, which redirects every old endpoint URL and puts each tag's endpoints back in ReadMe's order.
- Adds a
package.json, removes anyrdmeupload steps from CI, and runsblume build,blume validate --strict, andblume audit --only redirectsuntil they pass.
It ends with a summary of what it migrated, dropped, and approximated. Keep it for the review.
Review the changes
Start with git diff --stat, run npx blume dev, and check these, which are where a ReadMe migration most often needs a second look.
- Interactive widgets. A button with an
onClickpasted into a page does nothing in Blume, which warns about it asBLUME_MDX_EVENT_HANDLER. Hubs often paste the same one, such as a "contact support" box, into many pages. Each should now be one React island. The search below should print nothing outside code samples. - Page styles. Rules in a page's
<style>block, likeul { … }, restyle the whole site, sidebar included, once they move totheme.css. Pages that leaned on them should be rebuilt with components instead. - Custom components. Blume compiles Tailwind classes, but ReadMe's own CSS variables don't exist here, so check each block the agent ported or replaced, in light and dark mode.
- Callouts and code tabs. A blockquote that starts with an emoji was a callout on ReadMe; one the conversion missed shows as a plain quote. Fenced blocks written back to back were tabs; check the tab labels.
- Headings. Where a page used
#headings for its sections, every heading moved down one level, since Blume renders the title as the page's only H1. Anchors keep their ids. - Images. Images move off ReadMe's CDN into
public/. Where an image had no usable alt text, the agent derived one from the file name; read those. - Variables. Each
{{name}}needs a default invariables. ReadMe filled them per reader; Blume shows the default to everyone, so make sure no default is a real key. - Links that were already broken. Hubs collect links to pages from older versions, which 404 on ReadMe too. The agent points each at the closest page or unlinks it, and lists them.
- The API reference. Each OpenAPI file is its own group in the API tab, in ReadMe's category order, with endpoints in ReadMe's order inside each tag. Prose you wrote on an endpoint page, and callouts inside your spec's descriptions, now show on the operation page. A ReadMe category that held several files is split into one group per file, each with its own label.
- Hidden and custom pages. Pages hidden on ReadMe stay reachable by URL and out of the sidebar and search. Custom pages have no sidebar, as before.
grep -rnE 'on[A-Z][a-zA-Z]*=|<style|className="fa' \
docs reference recipes page changelog --include='*.mdx'Keep every old URL working
ReadMe serves every page at a flat URL however deeply it's nested, and Blume keeps those. API reference URLs change, because Blume builds them from your OpenAPI files:
| Old ReadMe URL | In Blume |
|---|---|
/docs/create-an-order, at any depth | The same page |
/changelog/12-05-2022 | The same entry, with the date whole in its URL |
/reference/getorder | Its operation page, by redirect: /reference/orders-api/orders/get-order, or /reference/orders/get-order with a single API file |
A tag page, like /reference/orders | A redirect to its first endpoint, as ReadMe did |
A parent page with no content of its own, and /docs | A redirect to the first page under it, as ReadMe did |
/v3.0/docs/create-an-order | A redirect to /docs/create-an-order |
Your sitemap leaves out hidden pages. The codemod's readme-migration.json lists every URL your repository served, hidden ones included, so add them to your list while the file is there:
node -p "require('./readme-migration.json').urls.map((u) => u.url).join('\n')" \
>> ../old-urls.txt
sort -u -o ../old-urls.txt ../old-urls.txtThen, with npx blume dev running, check every URL and print the ones that don't land on 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 run it again until it prints nothing.blume preview works too, after a build: both answer pattern redirects like the version prefixes. For status codes and how each host serves redirects, see Move documentation URLs while preserving old links.
Deploy and switch over
Build and run the checks the agent ran, so you see them pass yourself:
npx blume build
npx blume validate --strict
npx blume audit --only redirectsPick a host and follow Deployment. ReadMe sent Try it requests through its own proxy; Blume sends them from the reader's browser, so your API has to allow your docs origin. Send a preflight and look for an access-control-allow-origin header:
curl -si -X OPTIONS https://api.acme.example/v1/orders \
-H 'Origin: https://docs.acme.example' \
-H 'Access-Control-Request-Method: GET' \
| grep -i '^access-control-allow-origin'If it's missing, choose a host adapter with server output, such as vercel(), and set playground: { proxy: true } in the openapi() entry.
Disconnect Git sync before you merge (Settings → Git Connection), or ReadMe syncs the restructured branch into your live hub. Then push the branch, check a preview deployment, and rerun the URL check against it. Point your custom domain's DNS at the new host, and keep the ReadMe project until production passes the URL check, so rolling back is a DNS change. Submit the new sitemap.xml in Google Search Console, since search engines recrawl moved URLs on their own schedule.
What doesn't carry over
- The hosted editor. ReadMe's editor, branches and reviews, and suggested edits stay with ReadMe. In Blume, pages are files reviewed in pull requests, and each page can link to its source on GitHub.
- API metrics and logs. My Requests, API Metrics, and the developer dashboard read your API's traffic through ReadMe. Analytics in Blume come from the adapter you choose and cover the docs, not the API.
- Personalized docs. ReadMe filled in each reader's API key and variables after they logged in. Blume shows every reader the same page, and Try it takes the credentials they type.
- Discussions. The community forum doesn't move; link to wherever your community lives now from the header.
- Logins. Blume has no reader accounts. Use your host's protection, such as Vercel Deployment Protection or Cloudflare Access, which covers the whole site; mixing public and private pages takes two sites.
- The landing page and recipe walkthroughs. ReadMe's landing page builder doesn't sync to Git, so rebuild your home page as a Blume page. Recipes become ordinary pages; their step-by-step code modal doesn't carry over.
Next step
Migrate your docs
Run it at the root of the repository ReadMe syncs to, on a branch named after none of your versions, then work through the review above.
npx blume migrate readme --claudeA step here not working for you? Report a broken step.