Migrate
Migrate your docs from Nextra
Move a Nextra site's MDX and _meta navigation to Blume with a coding agent, check nested order and React components, and keep every URL working.
By Hayden Bleasel13 min read

To migrate from Nextra to Blume, run npx blume migrate nextra --claude in your Nextra project. A coding agent turns every _meta file into a meta.ts, writes page titles into frontmatter, swaps Nextra's components for Blume's, and carries your redirects over. Your MDX stays MDX. What needs your review is nested sidebar order, your own React components, and old URLs.
By the end, your docs build with blume build instead of next build, keep their URLs, and deploy on Vercel. The walkthrough follows a small Nextra 4 site with nested ordering and a custom React component, and notes where Nextra 2 and 3 differ.
This moves the docs, not the Next.js app around them. Other routes, middleware, and sign-in stay in Next.js. If your docs must render inside that app, behind its session or in its React layout, Nextra is the better fit, and Blume vs Nextra covers that trade-off.
What carries over
Your MDX pages move with targeted rewrites. The rest maps like this:
| In Nextra | In Blume |
|---|---|
theme.config.jsx, or the props in app/layout.jsx | blume.config.ts |
_meta files | A meta.ts per folder, plus sidebar.label and sidebar.hidden in page frontmatter |
Components from nextra/components | Blume's built-in components, with no imports |
| Your React components | Islands |
redirects() in next.config | redirects, patterns included |
public/ | public/, at the same URLs |
Built-in search, or Pagefind after next build | Built-in search, with no extra build step |
What doesn't carry over: _meta separators and menus, footer content, <Bleed>, most per-page theme switches, and anything the Next.js app does besides serving pages.
Before you start
The agent edits your repository in place, so start on a new branch with a clean working tree:
git switch -c migrate-to-blumeNextra 4 was close to a rewrite, so your version decides where the config, pages, and navigation files live. Check what your lockfile installs:
npm ls nextra nextra-theme-docs next --depth=0| Nextra 2 | Nextra 3 | Nextra 4 | |
|---|---|---|---|
| Router | Pages Router | Pages Router | App Router |
| Pages | pages/ | pages/ | content/, or app/**/page.mdx |
| Navigation | _meta.json | _meta.js or .ts | _meta.js or .ts, or one _meta.global file |
| Site config | theme.config.jsx | theme.config.jsx | Props in app/layout.jsx |
| Folder landing page | guides.mdx beside guides/ | guides.mdx beside guides/ | guides/index.mdx with asIndexPage: true |
| Components | Flat names: Tab, Card | Tabs.Tab, Cards.Card | Tabs.Tab, Cards.Card |
| Search | Built in | Built in | Pagefind, run by a postbuild script |
Then build the current site and save the list of URLs it serves, to check against the new site later. Next.js writes an HTML file per prerendered page, so the build output has the full list:
npm ci
npm run build
out=.next/server/app # .next/server/pages on Nextra 2 and 3
find "$out" -name '*.html' ! -name '_*' ! -name '404.html' ! -name '500.html' \
| sed -e "s#^$out##" -e 's#\.html$##' -e 's#^/index$#/#' \
| sort > ../old-urls.txtUse npm ci, not a fresh install: an old Nextra can break on dependencies newer than itself (see Troubleshooting). You also need Node.js 22.12 or later, and Claude Code or Codex installed and signed in.
Separate the docs from the Next.js app
Nextra is a plugin inside a Next.js app, so decide what that app becomes before anything moves:
- The app is only docs. Every route comes from Nextra: a
pages/folder,page.mdxfiles, orcontent/behind the[[...mdxPath]]catch-all. Blume replaces the app. - The docs are one part of a bigger app. Keep the app. The docs become their own Blume project in a separate folder, like
apps/docsin a monorepo, and the app drops thenextra()wrapper, the docs routes, and the_metafiles. Serve the docs on a subdomain, or at/docsthrough one rewrite, as Serve a separate docs site under /docs in Next.js shows.
Sign-in doesn't move with the docs. Blume has no auth of its own, so docs your middleware.ts or proxy.ts gated need your host's access protection. A rewrite can keep /docs behind your middleware, but the docs deployment's own URL bypasses it.
If Blume will serve the whole domain and the docs lived under /docs (Nextra 4's contentDirBasePath, or a pages/docs/ folder), keep the prefix with basePath. A React landing page like app/page.jsx needs rebuilding as a custom page.
The example site
This guide migrates a Nextra 4 site that uses the content directory. It nests a webhooks folder between two pages, hides one page, links to a status page, and imports a React component:
acme-docs/
├── app/
│ ├── layout.jsx
│ └── [[...mdxPath]]/page.jsx
├── components/
│ └── segment-counter.jsx
├── content/
│ ├── _meta.js
│ ├── index.mdx
│ ├── quickstart.mdx
│ ├── legacy-api.mdx
│ └── guides/
│ ├── _meta.js
│ ├── index.mdx
│ ├── send-email.mdx
│ ├── send-sms.mdx
│ └── webhooks/
│ ├── _meta.js
│ ├── setup.mdx
│ ├── verify.mdx
│ └── events.mdx
├── mdx-components.jsx
├── next.config.mjs
└── package.jsonThe three _meta files set the order and labels:
// content/_meta.js
export default {
index: 'Introduction',
quickstart: 'Quickstart',
guides: 'Guides',
'legacy-api': { display: 'hidden' },
status: { title: 'Status', href: 'https://status.acme.example' }
}
// content/guides/_meta.js
export default {
'send-email': 'Send an email',
webhooks: { title: 'Webhooks', theme: { collapsed: false } },
'send-sms': 'Send an SMS'
}
// content/guides/webhooks/_meta.js
export default {
setup: 'Set up an endpoint',
verify: 'Verify signatures',
events: 'Event types'
}guides/index.mdx sets asIndexPage: true, so it's the page behind the Guides folder, and send-email.mdx starts with # Send your first email, a longer heading than its sidebar label. The component and the page that uses it:
'use client'
import { useState } from 'react'
// Assumes GSM-7 text: one SMS holds 160 characters, and a longer
// message is sent as parts of 153, since each part carries a header.
export function SegmentCounter() {
const [text, setText] = useState('Your Acme code is 123456')
const segments = text.length <= 160 ? 1 : Math.ceil(text.length / 153)
return (
<div>
<textarea rows={3} value={text} onChange={(event) => setText(event.target.value)} />
<p>
{text.length} characters, {segments} {segments === 1 ? 'segment' : 'segments'}
</p>
</div>
)
}import { Callout } from 'nextra/components'
import { SegmentCounter } from '../../components/segment-counter'
# Send an SMS
Carriers split long texts into segments. Type a message to count them:
<SegmentCounter />
<Callout type="warning">
Some countries require a registered sender ID before you can send.
</Callout>
> [!NOTE]
> Replies to your number arrive as `message.received` webhook events.Nextra renders this sidebar: Introduction, Quickstart, then Guides with Send an email, the three Webhooks pages, and Send an SMS, then Status. On Nextra 3, the same site keeps its pages in pages/, has a theme.config.jsx, and uses pages/guides.mdx instead of asIndexPage. On Nextra 2, each _meta.js is a _meta.json with the same keys.
Run the migration
From the folder whose package.json lists nextra, run:
npx blume migrate nextra --claudeTo use Codex, swap --claude for --codex. On pnpm 12, run it with pnpm dlx --allow-build=esbuild instead of npx, since pnpm 12 won't run esbuild's install script until you approve it. Leave out nextra and Blume detects the source from the nextra dependency. Run it with neither flag and it only prints where the skill lives, for another agent to use.
blume migrate converts nothing itself. It names the source, then opens the agent in your terminal on the blume-migrate skill that ships inside the package, pointed at the skill's Nextra reference. The agent runs interactively, so its edits go through its usual permission prompts. Tell it up front whether the Next.js app stays and whether the docs keep a /docs prefix. Following the skill, it:
- Reads
theme.config.jsx(or the props inapp/layout.jsxon Nextra 4) andnext.config, and writesblume.config.ts. - Turns every
_metafile into ameta.tsplus page frontmatter. - Rewrites components, imports, and code block options.
- Copies your
next.configredirects, and adds one for any page it moves. - Swaps Nextra for Blume in
package.json, points the scripts atblume dev,blume build, andblume preview, then runsblume buildandblume validateuntil both pass.
For the example, the config comes out like this:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Docs",
content: { root: "content" },
github: { owner: "acme", repo: "docs" },
navigation: {
featured: [
{ label: "Status", href: "https://status.acme.example", icon: "activity" },
],
},
redirects: [
{ from: "/guides/email", to: "/guides/send-email", status: 308 },
{ from: "/webhooks/:path*", to: "/guides/webhooks/:path*", status: 308 },
],
});content.root points at Nextra 4's content/ folder. On Nextra 2 and 3, move pages/ to docs/, Blume's default, since Blume uses pages/ for custom .astro pages. The agent finishes with a summary of what it migrated, dropped, and approximated. Keep it: it's your checklist for the review.
Review the sidebar
Start with git diff --stat for the shape of the change, then run npx blume dev and compare the sidebar with the old site. Blume reads each folder's meta.ts, so a folder's title, which Nextra keeps in the parent's _meta, moves into the folder's own file:
// content/meta.ts
import { defineMeta } from "blume";
export default defineMeta({
pages: ["index", "quickstart", "guides", "legacy-api"],
});
// content/guides/meta.ts
import { defineMeta } from "blume";
export default defineMeta({
title: "Guides",
pages: ["send-email", "webhooks", "send-sms"],
});
// content/guides/webhooks/meta.ts
import { defineMeta } from "blume";
export default defineMeta({
title: "Webhooks",
collapsed: false,
pages: ["setup", "verify", "events"],
});Pages jump above a nested folder
You'll see Send an SMS above Webhooks, though pages lists it last. By default, Blume draws each folder as a flat section header. So that a page after a header can't pass for part of that section, it lists a folder's pages before its subfolders. Nextra draws folders as collapsible rows instead. Blume's group display does too, and keeps the order as written:
navigation: {
sidebar: { display: "group" },
},Set it for the whole sidebar, or in one subfolder's meta.ts. A group starts closed unless it holds the current page or sets collapsed: false, and Nextra opens top-level folders by default, so add that where a folder should start open. At the top level, Blume lists loose pages before folders in every display mode, so a root _meta that puts a page after a folder can't be matched.
Folder landing pages
Blume makes guides/index.mdx the page behind the Guides row, and also lists it as the folder's first entry. Hide that entry to keep Nextra's single row:
---
title: Guides
sidebar:
hidden: true
---
Task-by-task guides for the Acme API.Keep this title and the folder's meta.ts title the same, or Blume warns with BLUME_NAV_INDEX_TITLE_MISMATCH. The file differs by version: Nextra 4's asIndexPage key has to go, and Nextra 2 and 3's guides.mdx beside the folder moves to guides/index.mdx, which keeps the /guides URL. Left beside the folder, it shows up as a second Guides entry.
Titles and labels
Nextra takes a page's sidebar label from _meta and its title from the first heading. Blume renders frontmatter title as the heading, with sidebar.label for the sidebar. Where the two differ, check which became the title. Keeping the old heading:
---
title: Send your first email
sidebar:
label: Send an email
---The body's # Send your first email line goes, or the page shows its title twice.
The rest of _meta
display: 'hidden'becomessidebar.hidden: true. The page still builds at its URL.- An
hreflink becomes a featured link above the sidebar, like Status above, or a header link innavigation.actions. - A root entry with
type: 'page'becomes a header tab innavigation.tabs. - Separators, menus, and most
themeoptions have no equivalent, and the agent lists them. A page withtheme: { layout: 'full' }comes closest to mode: wide.
Translated docs
Nextra 2 and 3's filename suffixes, like index.en.mdx, work in place with parser: "dot". Nextra 4 gives every language a folder, and the skill leaves them as they are. Blume keeps the default language at the content root, though, so a content/en/ folder publishes as ordinary pages and warns with BLUME_I18N_DEFAULT_LOCALE_FOLDER. Move its contents up a level, and set hideDefaultLocalePrefix: false to keep serving them under /en/, as Nextra did.
Review pages and components
Blume's components need no imports, so the agent deletes every nextra import and rewrites what it imported:
| In Nextra | In Blume |
|---|---|
<Callout type="warning"> | :::warning, and type="error" becomes :::danger |
> [!NOTE] alerts | :::note and the other directives |
<Tabs items={['curl', 'Node.js']}> of <Tabs.Tab> | <Tabs> of <Tab title="curl"> |
<Cards.Card>, or <Card> on Nextra 2 | <CardGroup> of <Card> |
<Steps> around ### headings | <Steps> of <Step title="…"> |
<FileTree.Folder> | <Tree.Folder> |
```js filename="send.js" showLineNumbers | ```js send.js lineNumbers |
```sh npm2yarn | ```package-install |
Inline math $x$, with latex: true | $$x$$ |
Directives and these fences only work in .mdx pages, so the agent renames any .md page that gains one. Line ranges like {1,4-5} carry over unchanged. The skill treats inline math as unsupported, so check that the agent kept each $x$ inline as $$x$$ rather than moving it onto its own line or dropping it.
React components become islands
Blume renders pages as static HTML. A component that runs in the browser becomes an island: a file in islands/ at the project root, used by its filename in any .mdx page with no import. The segment counter becomes:
import { useState } from "react";
// Assumes GSM-7 text: one SMS holds 160 characters, and a longer
// message is sent as parts of 153, since each part carries a header.
export default function SegmentCounter() {
const [text, setText] = useState("Your Acme code is 123456");
const segments = text.length <= 160 ? 1 : Math.ceil(text.length / 153);
return (
<div>
<textarea rows={3} value={text} onChange={(event) => setText(event.target.value)} />
<p>
{text.length} characters, {segments} {segments === 1 ? "segment" : "segments"}
</p>
</div>
);
}The filename is PascalCase because it becomes the tag, the component is the default export, and 'use client' is gone: an island always hydrates, by default when it scrolls into view. Add export const client = "only" to one that needs window. The page loses its imports and heading:
---
title: Send an SMS
---
Carriers split long texts into segments. Type a message to count them:
<SegmentCounter />
:::warning
Some countries require a registered sender ID before you can send.
:::
:::note
Replies to your number arrive as `message.received` webhook events.
:::Next.js APIs don't exist here: next/link becomes a plain link, next/image a Markdown image, and a router hook usePage() from blume/hooks. Keep react and react-dom in your dependencies, since islands import them.
Check every old URL
Pages keep their paths: content/guides/send-email.mdx is /guides/send-email in both. The exception is a numeric prefix like 01-, which Blume reads as sort order and drops from the URL. The agent copies each next.config redirect as written, with permanent: true as status: 308 and false as 307. Rules with has or missing conditions or regex parameters have no Blume form, and the agent lists them for your host's config.
With npx blume dev running, print every old URL that no longer lands on a page:
while read -r path; do
code=$(curl -sL -o /dev/null -w "%{http_code}" "http://localhost:4321$path")
[ "$code" = "200" ] || echo "$code $path"
done < ../old-urls.txtRedirects count as passing, since -L follows them. Add a redirect for each path it prints, and run it until it prints nothing. Blume's URLs never end in a slash, so if your next.config set trailingSlash: true, check a few slashed links on the deployed site too. For status codes, patterns, and what each host does with them, see Move documentation URLs while preserving old links.
Build and validate
In a docs-only app, check that the agent removed next, nextra, nextra-theme-docs, any Pagefind postbuild script, next.config.mjs, mdx-components.jsx, app/ (or theme.config.jsx and pages/_app.jsx on Nextra 2 and 3), and next-env.d.ts. Then stop the dev server, build the site, and check its links:
npx blume build
npx blume validate --strictblume build fails on any page, route, or config error, including Nextra frontmatter keys. blume validate --strict checks every internal link, heading anchor, and asset, and fails on warnings too. Fix what they report rather than passing --no-strict, which drops the pages that fail.
Deploy and switch over
If your Nextra site runs on Vercel, the Blume site can stay in the same project. Add the vercel() adapter, so redirects are answered with real HTTP status codes:
import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
export default defineConfig({
// ...everything the migration wrote
deployment: vercel(),
});The project is still set to build Next.js, and a preset change in the dashboard would apply to your main branch too. Commit a vercel.json beside package.json instead. "framework": null selects the Other preset for the deployments that include it:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": null,
"buildCommand": "npm run build"
}Check that the project's Node.js version is 22 or later, push the branch, and run the URL loop against its preview URL. If Vercel Authentication protects your previews, curl gets a login page, so check a sample in the browser and run the full loop after the switch. Merging deploys to your domain, and Vercel's Instant Rollback can put the last Nextra deployment back. For other hosts, see Deployment.
Troubleshooting
The old Nextra site no longer builds
A fresh install resolves newer dependencies than your Nextra release shipped with. Nextra 4.6.1 with zod 4.4 or later fails to prerender every page with Invalid input: expected nonoptional, received undefined. Nextra 3.3.1 with TypeScript 7 fails to compile pages with Cannot read properties of undefined (reading 'readFile'). Install from your lockfile, or pin the package with overrides in package.json, like "overrides": { "zod": "4.3.6" }.
The build fails on asIndexPage or sidebarTitle
Blume's frontmatter is strict, so Nextra's keys stop the build with BLUME_FRONTMATTER_INVALID. Rename sidebarTitle to sidebar.label, and delete asIndexPage: the name index.mdx already makes a file the folder's page.
A code block's title reads showLineNumbers, copy, or npm2yarn
Blume titles a code block with the first plain word after the language and skips key="value" options, so leftover Nextra options become titles and filename="…" is lost. Rewrite the fence as the table above shows.
A page shows its title twice
The body still starts with the # heading Nextra used as its title. Delete it. After a build, blume audit reports these pages as BLUME_AUDIT_H1_MULTIPLE.
npm run build fails after Blume finishes
npm runs a postbuild script after every build. Nextra 4's runs Pagefind against .next/server/app, which no longer exists, and exits with "Pagefind was not able to build an index." Delete the script and the pagefind dependency.
An island doesn't render
Blume skips an island named like segment-counter.jsx, with a build warning. Use a PascalCase filename and a default export.
A redirect fails the build
BLUME_REDIRECT_MATCHES_PAGE means a redirect pattern, like one copied from next.config, also covers a page you kept. Narrow it to the URLs that moved.
Next step
Migrate your docs
Run it in the folder whose package.json lists nextra, on a clean branch, then work through the review above.
npx blume migrate nextra --claudeA step here not working for you? Report a broken step.