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

Migrate

Migrate your docs from Fern

Hand your Fern repository to a coding agent, keep every page URL and redirect every endpoint, export a Fern Definition to OpenAPI, and deploy docs you host yourself while Fern keeps generating your SDKs.

By 11 min read

By the end of this guide, your Fern docs are a Blume project in the same repository: every page at the URL it has today, your docs.yml navigation rebuilt as folders and tabs, your API Reference rendered from OpenAPI, and a redirect for every endpoint and changelog URL that moved. A coding agent does the conversion, and you review it.

Only the docs move. The same fern/ folder usually generates your SDKs, and that part stays exactly as it is: your Fern Definition or OpenAPI spec, generators.yml, and fern check in CI keep working.

What carries over

Fern pages are MDX, so the content needs little more than component renames. The work is elsewhere: Fern builds every URL from docs.yml rather than from file paths, and its API Reference URLs follow its own rules. On one real Fern site with 115 pages and 149 endpoints, every one of its 270 old URLs ended up either serving a page or redirecting in one hop.

In FernIn Blume
docs.yml settingsblume.config.ts
Tabs, sections, and page order in docs.ymlHeader tabs, folders placed so each page keeps its URL, and a meta.ts per folder for titles, icons, and order
<Note>, <Warning>, and the other callouts:::note, :::warning, and the other directives
<CodeBlocks>, <Cards>, <AccordionGroup><CodeGroup>, <CardGroup>, <Accordion>
<Markdown src> snippets<include> and a _snippets/ folder
Font Awesome iconsLucide icons, where one exists
A changelog folder of dated entriesBlume's changelog, with an RSS feed
A Fern DefinitionAn OpenAPI file exported by the Fern CLI, rendered by openapi() with a page per endpoint and webhook
redirects in docs.ymlredirects in blume.config.ts

Here's one page before and after the conversion:

---
title: Send your first order
subtitle: Create an order with the Acme API in five minutes.
---

<Note title="Sandbox keys">
  Test keys never charge a card.
</Note>

<CodeBlocks>
```python
import acme

client = acme.Client(api_key="sk_test_123")
```

```typescript
import { AcmeClient } from "acme";

const client = new AcmeClient({ apiKey: "sk_test_123" });
```
</CodeBlocks>

<Cards>
  <Card title="Authentication" icon="fa-solid fa-key" href="/core-concepts/authentication">
    Create and rotate API keys.
  </Card>
</Cards>
---
title: Send your first order
description: Create an order with the Acme API in five minutes.
sidebar:
  label: Quickstart
---

:::note[Sandbox keys]
Test keys never charge a card.
:::

<CodeGroup>
```python title="Python"
import acme

client = acme.Client(api_key="sk_test_123")
```

```typescript title="TypeScript"
import { AcmeClient } from "acme";

const client = new AcmeClient({ apiKey: "sk_test_123" });
```
</CodeGroup>

<CardGroup>
  <Card title="Authentication" icon="key" href="/core-concepts/authentication">
    Create and rotate API keys.
  </Card>
</CardGroup>

The page sat in a "Get Started" section that added nothing to its URL, so it lands in a (get-started) folder, which groups pages in the sidebar without changing their URLs, and still serves at /quickstart. Fern's subtitle becomes Blume's description, which renders under the title, and the page: name from docs.yml stays the sidebar label. The code tabs keep their language names, which Fern showed for untitled blocks.

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 check where your docs are published. Each entry under instances in fern/docs.yml is a deployment of the same docs:

instances:
  - url: https://acme.docs.buildwithfern.com
    custom-domain: docs.acme.example
  - url: https://acme.docs.buildwithfern.com/docs
    custom-domain: acme.example/docs

A Blume build serves one of them, so pick the one that stays canonical. An instance with a path in its domain, like acme.example/docs, is served under that path through a proxy; you'll decide what replaces it when you switch over.

Save every URL your site serves today. Fern's sitemap lists each page, API endpoint, webhook, and changelog date:

curl -s https://docs.acme.example/sitemap.xml \
  | grep -o '<loc>[^<]*' \
  | sed -e 's#<loc>https://[^/]*##' -e 's#^$#/#' \
  > ../old-urls.txt

Replace docs.acme.example with your docs domain. The sitemap leaves out pages marked hidden: true in docs.yml, which still serve at their URLs; you'll add those after the migration. 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, the folder that holds fern/, run:

npx blume migrate fern --claude

To use Codex, swap --claude for --codex. Leave out fern and Blume detects the source from fern/docs.yml. A fern/ folder without a docs.yml only generates SDKs, so Blume doesn't treat it as a docs site.

blume migrate converts nothing itself. It opens the agent on the blume-migrate skill inside the package, pointed at its Fern reference. Following it, the agent:

  1. Runs the skill's Fern codemod, which computes the URL of every page from docs.yml, hidden ones included, and checks them against your sitemap before it moves anything.
  2. Moves each page to a path that reproduces its URL, writes the meta.ts files and frontmatter, converts the components, and moves your assets to public/.
  3. Writes blume.config.ts from docs.yml, and adds an openapi() reference, exporting a Fern Definition to OpenAPI first.
  4. Builds once, reads the new endpoint routes from the build, and generates a redirect for every old endpoint, API group, changelog date, and moved page.
  5. Converts what the codemod left for judgment, like embedded endpoint snippets and changelog titles, then removes the docs from fern/ and updates package.json, CI, and your contributor docs.
  6. Runs blume build, blume validate --strict, blume audit --only redirects, and fern check until they pass.

It ends with a summary of what it migrated, dropped, and approximated. Keep it for the review.

Keep SDK generation

The agent removes only what the docs used. Everything Fern needs to generate SDKs stays in place:

fern/
├── fern.config.json      kept: pins the Fern CLI
├── apis/api/
│   ├── generators.yml    kept: SDK generation
│   └── definition/       kept: the API definition
├── docs.yml              removed
├── pages/                → docs/
├── changelog/            → docs/changelog/
├── snippets/             → docs/_snippets/
└── assets/               → public/assets/

CI steps that run fern check or fern generate --group for an SDK stay too, along with the FERN_TOKEN secret if they use it. The steps that publish or preview the docs (fern generate --docs) go, replaced by your new host's deploy. After the migration, run fern check yourself to confirm the SDK config still loads.

From a Fern Definition to an API reference

If your API is an OpenAPI spec, Blume renders it as it is. A Fern Definition, the definition/ folder of YAML files, Blume can't read, so the agent exports it with the Fern CLI version your project pins. The export runs locally, with no login (api is the API's folder name under fern/apis/):

npx fern-api@$(node -p "require('./fern/fern.config.json').version") \
  export --api api openapi/openapi.yml

If your repository already commits an OpenAPI file generated from the definition, the agent points Blume at that file instead and checks it against a fresh export. When they differ, it reports that the committed file is stale rather than adding a second copy. Refresh it the way your repository already does, and check first whether a release workflow runs when that file changes.

The export leaves out webhooks, WebSocket channels, and idempotency headers. The webhook payload schemas survive in it, so the agent restores each webhook page with an OpenAPI Overlay, a file of edits applied at build time, which keeps the export itself regenerable:

overlay: 1.1.0
info: { title: Docs fixes, version: 1.0.0 }
actions:
  # Fern packages become PascalCase tags: name them.
  - target: $.paths.*.*.tags[?@ == 'OrdersRefunds']
    update: Order Refunds
  # The export drops webhooks; their payload schemas stay in components.
  - target: $
    update:
      x-webhooks:
        orderShipped:
          post:
            summary: Order Shipped
            tags: [Webhook Events]
            requestBody:
              content:
                application/json:
                  schema:
                    $ref: "#/components/schemas/OrderShippedEvent"
            responses:
              "200":
                description: Return a 2xx status to acknowledge the event.

The same overlay names Fern's nested packages, which the export turns into tags like OrdersRefunds, and sets their order. A WebSocket channel has no page in Blume, so its old URL redirects to the guide that covers it. The config ends up like this:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

import fernRedirects from "./fern-redirects.json" with { type: "json" };

export default defineConfig({
  title: "Acme Docs",
  logo: { image: "/logo.svg", text: "", href: "/quickstart" },
  navigation: {
    tabs: [
      { label: "Documentation", path: "/", icon: "book" },
      { label: "API Reference", path: "/api-reference", icon: "terminal" },
      { label: "Changelog", path: "/changelog", icon: "clock" },
    ],
  },
  reference: [
    openapi({
      spec: "./openapi/openapi.yml",
      overlays: ["./openapi/docs.overlay.yaml"],
      route: "/api-reference",
      // Fern's default-language first.
      codeSamples: ["python", "typescript", "curl"],
      playground: false,
    }),
  ],
  // One entry per moved page, endpoint, API group, and changelog date.
  redirects: [...fernRedirects],
});

Review the changes

Start with git diff --stat, run npx blume dev, and check these, which are where a Fern migration most often needs a second look.

  • No Fern components left. A Fern tag Blume doesn't know either warns in the build or breaks the page. The search below should print nothing:
grep -rnE '<(Note|Tip|Info|Warning|Success|Error|Check|Launch|CodeBlocks?|Cards|AccordionGroup|Markdown|Endpoint[A-Za-z]*Snippet|Availability|Files)[ />]' docs
  • Sidebar order. Click through each tab. Blume lists a section's pages above its subsections, so a section that mixed them shows its pages first. Labels match Fern's: a page's docs.yml name becomes its sidebar label where it differs from the title.
  • Callout icons. Callouts at the top level of a page become :::note-style directives, which take no icon. A callout with an icon, or one inside another component, stays a <Callout> and keeps its icon where Lucide has one.
  • Icons. Font Awesome names map to Lucide where one exists. Brand icons like Discord and Google have no Lucide equivalent and are dropped; the agent's summary lists where.
  • Hidden pages. Each one keeps its URL, out of the sidebar, search, and the sitemap, with a noindex tag.
  • Changelog titles. An entry with a name after its date, like 2026-09-05-agentid-sign-in-keys.mdx, takes its title from the file name, so check the casing. Fern showed two entries from the same day as one page; Blume gives each its own, and the old date URL redirects to the one without a name after its date, when there is one.
  • The API reference. Tags carry readable names, webhook pages are back, and operations within a tag follow the order of the exported spec. Nested sections like Orders › Refunds become sibling groups.
  • Embedded endpoint snippets. Blume can't embed an operation's request or response in a guide, so each <EndpointRequestSnippet> becomes a written sample plus a link to the endpoint's page. Check them against your API.
  • Drafts. Fern published only the files docs.yml referenced, and only those move with the pages. The agent moves the others into a _drafts/ folder, which Blume doesn't publish, or deletes them if you say so.
  • Links that were already broken. blume validate checks every internal link, so it finds links that 404 on your live Fern site too. The agent fixes them and lists them.
  • The repository. Contributor docs and scripts that wrote into fern/pages, fern/changelog, or fern/snippets point at the new paths; .gitignore ignores .blume/ and dist/; and the lockfile is regenerated. A workflow that stamps last-updated into frontmatter goes, replaced by Blume's dates from git (lastModified: "git").

When your main tab's pages share no URL prefix, the tab sits at /, where no page lives. The tab links to your first page, and / redirects there, as Fern sent it.

Keep every old URL working

Content pages keep their exact URLs. API and changelog URLs don't, because Fern and Blume build them differently:

Old Fern URLIn Blume
/quickstartThe same page
/api-reference/orders/refunds/list/api-reference/order-refunds/orders-refunds-list, by redirect
/api-reference/orders/refunds, a groupThat tag on the API overview, /api-reference#order-refunds
/api-reference/webhooks/events/order-shipped/api-reference/webhook-events/order-shipped, by redirect
/changelog/2026/9/5/changelog/2026-09-05, by redirect
/changelog.rss/changelog/rss.xml, by redirect

The agent generates these rather than typing them: it reads each endpoint's new route from the build and matches it to the URL Fern gave it. The list lands in fern-redirects.json, which the config imports, alongside the redirects from your docs.yml. A docs.yml redirect whose destination already 404ed on Fern gets pointed at the page it meant, and the summary lists it.

Add the hidden pages to your saved list first. The codemod recorded every URL it computed from docs.yml, hidden pages included, in fern-migration.json:

node -e "console.log(require('./fern-migration.json').oldUrls.join('\n'))" \
  | sort -u - ../old-urls.txt -o ../old-urls.txt

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 url; do
  code=$(curl -sL -o /dev/null -w "%{http_code}" "http://localhost:4321$url")
  [ "$code" = "200" ] || echo "$code $url"
done < ../old-urls.txt

Redirects count as passing, since -L follows them. If it prints a path, add a redirect for it and run it again until it prints nothing. For status codes, patterns, and how each host serves them, 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 redirects

Pick a host and follow Deployment. Vercel and Netlify give Blume your domain automatically; on any other host, set deployment.site to it, for canonical URLs, the sitemap, and the RSS feed.

Fern's API Explorer sent requests through its own proxy. Blume's Try it panel sends them from the reader's browser, so it only works if your API allows your docs origin. Send a preflight from it 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 there's none, the agent turns Try it off. To keep it, choose a host adapter with server output, such as vercel(), and set playground: { proxy: true } in the openapi() entry, as CORS and the proxy describes.

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 unpublish the docs in the Fern dashboard only once production has passed the URL check. If you had a second instance under a path, like acme.example/docs, either redirect that path to your docs domain where it's proxied, or serve a second build there with deployment: { base: "/docs" }. Submit the new sitemap.xml in Google Search Console, since search engines recrawl moved URLs on their own schedule.

What doesn't carry over

  • API Explorer sign-in. Fern could fill in a reader's API key after they logged in, and run an OAuth flow. Blume's Try it panel takes a token, an API key, or basic credentials that the reader pastes.
  • Ask Fern. Fern's AI search and chat are hosted features. Blume's search works out of the box, and its AI assistant is opt-in and runs on your own provider key, with server output.
  • The hosted MCP server. Fern served one for your docs. Blume has its own MCP server, which needs server output; a static build has none. Update any page that gives readers the old server's URL. The Markdown copy of each page and llms.txt carry over on any host.
  • SDK snippets in the API reference. Fern showed calls from your generated SDKs on each endpoint. Blume generates HTTP samples (curl, JavaScript, Python, and more). To show SDK calls instead, add them as x-codeSamples in the overlay, and Blume shows them first.
  • The dashboard. Fern's editor, preview links, analytics, and access controls stay with Fern. In Blume, pages are files reviewed in pull requests, analytics come from the adapter you choose, and private docs use your host's protection.

Next step

Migrate your docs

Run it at the root of your repository, the folder that holds fern/, on a clean branch, then work through the review above.

npx blume migrate fern --claude
Read the migration reference

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

Keep going.More guides.

  • 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.

  • 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.

Upgrade your docs with Blume.

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

npx blume init