Migrate
Migrate your docs from Redocly
Hand your Redocly project to a coding agent, convert Markdoc to MDX, keep every API reference URL with generated redirects, and deploy docs you host yourself.
By Hayden Bleasel8 min read

By the end of this guide, your Redocly docs are a Blume project in the same repository: your pages converted from Markdoc to MDX, your sidebars rebuilt as folders and tabs, every OpenAPI description rendered as a native API reference, and every old URL either still serving its page or redirecting to it. A coding agent does the conversion, and you review it.
It covers projects built on Redocly's current platform, Realm (and Redoc, Revel, and Reef, which share its project format), whether you edit them in Reunite or locally. A repository whose redocly.yaml only configures redocly lint isn't a docs site to migrate: Blume leaves that file alone.
What carries over
Your content is Markdown either way, so the work is in Markdoc's tags, Redocly's navigation files, and how API reference URLs are built. On one real Realm site with 39 pages and 8 APIs, the run ended with every page built and 366 of its 370 old URLs covered; the other 4 were pages Realm had published by accident.
| In Redocly | In Blume |
|---|---|
redocly.yaml site settings | blume.config.ts; the lint and bundle settings stay in a trimmed redocly.yaml |
sidebars.yaml groups | Folders, with a meta.ts for labels and order |
| One sidebar per section, or sidebar tabs | Header tabs, one folder each |
{% admonition %} | :::info, :::warning, and the other callouts |
{% tabs %} | <Tabs>, or <CodeGroup> when every tab is code |
{% partial %} and _partials/ | <include> and the same _partials/ folders |
The page's first # H1 | Its frontmatter title |
| An OpenAPI file in the content tree | An openapi() reference with a page per operation, at the file's old path, or at its folder's when the file is alone there |
Tag order from x-tagGroups | A small meta.ts per tag |
redirects, in config and page frontmatter | redirects in blume.config.ts |
static/ | public/, at the same URLs |
@theme/styles.css | theme.accent, plus a root theme.css for your own classes |
index.page.tsx and other React pages | Custom Astro pages, with interactive parts as React islands |
scripts.head and analytics | script() and analytics adapters |
Here's one page before and after the conversion:
---
title: Orders quick start | Acme Developer Portal
---
# Orders quick start
{% admonition type="warning" name="Sandbox only" %}
Test keys never charge a card.
{% /admonition %}
{% partial file="/_partials/_api-key.md" /%}
{% tabs %}
{% tab label="cURL" %}
```bash
curl https://api.acme.example/v1/orders
```
{% /tab %}
{% /tabs %}---
title: Orders quick start
---
:::warning[Sandbox only]
Test keys never charge a card.
:::
<include>/_partials/_api-key.md</include>
<CodeGroup>
```bash title="cURL"
curl https://api.acme.example/v1/orders
```
</CodeGroup>Pages that end up with no directive, component, or Mermaid diagram stay .md. If you turn on Blume's "last updated" dates, expect nearly every page to show the migration commit's date: moving each H1 edits the page.
Before you start
Start from a clean working tree on a new branch, so the migration is one diff to review:
git switch -c migrate-to-blumeThen save every URL your site serves today. Realm's sitemap lists each page and also each API overview, tag page, and operation page, which is where most of your URLs live:
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 docs domain in both places. Realm publishes a sitemap only when seo.siteUrl is set; without one, list the URLs from your analytics or search console instead. Check that you have Node.js 22.19 or later, and that Claude Code or Codex is installed and signed in. If you edit in Reunite, note the settings that live there rather than in the repository, such as environment variables and your custom domain.
Run the migration
From the root of your Redocly project, run:
npx blume migrate redocly --claudeTo use Codex, swap --claude for --codex. Leave out redocly and Blume detects the source from a root sidebars.yaml or a Realm package in package.json; it never treats a redocly.yaml alone as a docs site.
blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its Redocly reference. Following it, the agent:
- Writes
blume.config.tsfromredocly.yaml, keeping your content folders where they are. - Converts every Markdoc tag, moves each H1 into
title, and fixes what MDX reads differently, such as<https://…>autolinks and inlinestylestrings. - Rebuilds your sidebars as folders,
meta.tsfiles, and tabs. - Adds an
openapi()reference for each description, with tag labels and any overlays a spec needs. - Builds once, reads the new operation routes from the build with a bundled script, and generates a redirect for every old API URL.
- Moves
static/topublic/, swaps Realm for Blume inpackage.json, trimsredocly.yamlto its lint settings, 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 Redocly migration most often needs a second look.
- No Markdoc left. Blume doesn't read
{% … %}tags, so one the agent missed shows up as literal text, and the build only warns about it asBLUME_TEMPLATE_TAG. The search below should print only code samples that show Markdoc on purpose, files the migration left out of the site, and any old operation links, covered next. - Titles. Realm showed each page's H1 and ignored a frontmatter
title, so the H1 becomes the title and the old frontmatter one is dropped. Blume also appends your site title to each page's browser tab title, which Realm didn't unless you setseo.projectTitle. - Navigation. Click through each tab. A group that existed only in
sidebars.yamlbecomes a real folder, and its moved pages get redirects. Sidebar separators have no Blume equivalent. - API references. Each API should show its tags under their display names, with each tag's operations in spec order. Tags follow the spec's
tagslist rather than itsx-tagGroups, and tags yourx-tagGroupsleft out, which Realm hid, now appear. - Old operation links. Realm resolved links like
#operation/getOrderin the browser. Blume can't redirect a fragment, so every one should now point at a Blume route, including the ones inside your OpenAPI descriptions. - Try it. Realm sent Try it requests through its own CORS proxy. Blume sends them from the reader's browser, so an API that doesn't allow your docs origin fails there until you set up a proxy (next section).
- Head scripts. Scripts from
scripts.headbecomescript()adapters, which load only in production builds. A script your pages need to work, like a support widget, belongs in a layout slot instead. - The repository.
.gitignoreno longer hidespublic/and now ignores.blume/anddist/;.nvmrcand CI use Node 22.19 or later; the lockfile is regenerated. If you runredocly lint, run it once and check it still loads its config.
grep -rnE '\{%|#operation/|#tag/' . \
--include='*.md' --include='*.mdx' --include='*.yaml' --include='*.json' \
--exclude-dir=node_modules --exclude-dir=dist --exclude-dir=.blumeIf an API's x-tagGroups put its tags in another order than its tags list, add a meta.ts in each tag's folder under the reference route, for example apis/orders/openapi/search/meta.ts, with the tag's position. Blume keeps the tag's label:
import { defineMeta } from "blume";
// The tag's place in the spec's x-tagGroups, which Blume doesn't read.
export default defineMeta({ order: 0 });Keep every old URL working
Content pages keep their paths, apart from the few a rebuilt sidebar group moved, which get redirects. API reference URLs don't, because Realm and Blume build them differently:
| Old Realm URL | In Blume |
|---|---|
/apis/orders/openapi | The same overview, when the route is kept |
/apis/orders/openapi/orders/createorder | /apis/orders/openapi/orders/create-order, by redirect |
/apis/orders/openapi/orders/paths/~1orders~1%7Bid%7D/get | /apis/orders/openapi/orders/get-orders-id, by redirect |
Tag and section pages, like /apis/orders/openapi/section/authentication | A redirect to the API's overview |
The agent generates one redirect per operation rather than typing them, and reads Blume's side from the build. The script it uses ships with Blume, so you can rerun it after changing a spec:
npx blume build
node node_modules/blume/skills/blume-migrate/scripts/operation-routes.mjs dist \
> operation-routes.json{
"references": {
"/apis/orders/openapi": {
"endpoints": {
"GET /orders/{id}": "/apis/orders/openapi/orders/get-orders-id",
"POST /orders": "/apis/orders/openapi/orders/create-order"
},
"title": "Orders API",
"webhooks": []
}
}
}Each endpoint, as written in the spec, maps to the page Blume built for it. Match an old URL to its endpoint through the spec's operationId or path, then look it up here. For a large site, keep the generated list in its own module, as in this config:
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
import { redirects } from "./redirects.ts";
export default defineConfig({
title: "Acme Developer Portal",
content: {
root: ".",
include: ["guides/**/*.{md,mdx}", "apis/**/*.{md,mdx}"],
},
reference: [
openapi({
// codeSamples.languages from redocly.yaml, in Blume's names.
codeSamples: ["curl", "node", "python", "go"],
sources: [
// Beside its guides: keeps its old path.
{ spec: "apis/orders/openapi.yaml", route: "/apis/orders/openapi", label: "Orders API" },
// Alone in its folder: the folder becomes the reference.
{ spec: "apis/billing/openapi.yaml", route: "/apis/billing", label: "Billing API" },
],
}),
],
redirects,
});Then, with npx blume dev running, check every URL you saved 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. Leave out the pages Realm published by accident, like a .github pull request template, unless you want them covered. For status codes, patterns, and how each host serves them, see Move documentation URLs while preserving old links.
Old links with a trailing slash need one more check. Realm redirected /page/ to /page, and so do blume dev and blume preview, but Blume's static build doesn't answer the slashed form itself, so in production it depends on your host. Try one on your preview deployment.
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. If any API failed your CORS check, choose a host adapter with server output, such as vercel(), and set playground: { proxy: true } in the openapi() entry that holds that API. To test an API's server, send a preflight from your docs origin 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'Push the branch and check a preview deployment, rerunning the URL check against it. Then point your custom domain's DNS at the new host, and keep the Reunite project until production has passed 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
- Login, RBAC, and SSO. 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 API explorer's extras. Blume's Try it panel takes a token, an API key, or basic credentials, but has no saved environments, mock server, or OAuth flow.
- Reunite. The hosted editor, review and preview workflow, and feedback and analytics dashboards stay with Redocly. In Blume, pages are files reviewed in pull requests, and analytics come from the adapter you choose.
- Markdoc's dynamic features. Conditionals and per-reader variables become the content the public site showed. PlantUML and Excalidraw diagrams need exporting to images; Mermaid carries over.
- Your lint setup. This one stays.
redocly.yamlkeeps itsapis,extends, andrules, and@redocly/clistays in your dependencies if it was there, soredocly lintandredocly bundlerun as before.
Next step
Migrate your docs
Run it at the root of your Redocly project, on a clean branch, then work through the review above.
npx blume migrate redocly --claudeA step here not working for you? Report a broken step.