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

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 8 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 RedoclyIn Blume
redocly.yaml site settingsblume.config.ts; the lint and bundle settings stay in a trimmed redocly.yaml
sidebars.yaml groupsFolders, with a meta.ts for labels and order
One sidebar per section, or sidebar tabsHeader 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 # H1Its frontmatter title
An OpenAPI file in the content treeAn 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-tagGroupsA small meta.ts per tag
redirects, in config and page frontmatterredirects in blume.config.ts
static/public/, at the same URLs
@theme/styles.csstheme.accent, plus a root theme.css for your own classes
index.page.tsx and other React pagesCustom Astro pages, with interactive parts as React islands
scripts.head and analyticsscript() 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-blume

Then 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.txt

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

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

  1. Writes blume.config.ts from redocly.yaml, keeping your content folders where they are.
  2. Converts every Markdoc tag, moves each H1 into title, and fixes what MDX reads differently, such as <https://…> autolinks and inline style strings.
  3. Rebuilds your sidebars as folders, meta.ts files, and tabs.
  4. Adds an openapi() reference for each description, with tag labels and any overlays a spec needs.
  5. Builds once, reads the new operation routes from the build with a bundled script, and generates a redirect for every old API URL.
  6. Moves static/ to public/, swaps Realm for Blume in package.json, trims redocly.yaml to its lint settings, and runs blume build, blume validate --strict, and blume audit --only redirects until 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 as BLUME_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 set seo.projectTitle.
  • Navigation. Click through each tab. A group that existed only in sidebars.yaml becomes 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 tags list rather than its x-tagGroups, and tags your x-tagGroups left out, which Realm hid, now appear.
  • Old operation links. Realm resolved links like #operation/getOrder in 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.head become script() 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. .gitignore no longer hides public/ and now ignores .blume/ and dist/; .nvmrc and CI use Node 22.19 or later; the lockfile is regenerated. If you run redocly 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=.blume

If 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 URLIn Blume
/apis/orders/openapiThe 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/authenticationA 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.txt

Redirects 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 redirects

Pick 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.yaml keeps its apis, extends, and rules, and @redocly/cli stays in your dependencies if it was there, so redocly lint and redocly bundle run 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 --claude
Read the migration reference

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

Keep going.More guides.

  • Migrate your docs from VuePress

    Hand your VuePress site to a coding agent, turn its Vue-flavored Markdown into Blume pages in every language, and keep every old .html URL and heading anchor working.

  • Migrate your docs from Docus

    Hand your Docus site to a coding agent, convert its MDC components with a codemod, turn sections into tabs without moving a URL, and keep your assistant, MCP server, redirects, and heading anchors.

  • Migrate your docs from Docsify

    Hand your Docsify site to a coding agent, convert its callouts, tabs, and includes with a codemod, rebuild its sidebar as folders, and keep every old #/ link and heading anchor working.

Upgrade your docs with Blume.

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

npx blume init