Migrate
Migrate your docs from Starlight
Move a Starlight site to Blume with a coding agent, keep its pages and URLs, and rebuild the overrides and splash pages that stay Astro work.
By Hayden Bleasel11 min read

Yes. Run npx blume migrate starlight --claude in your Starlight project, and a coding agent converts it to Blume in place. Your pages stay in src/content/docs, the starlight() call becomes a blume.config.ts, Starlight's components become Blume's, and most URLs keep their path, minus the trailing slash. You review the diff, rebuild your custom components, and check each old URL before you switch.
This guide follows a small Starlight site through the move: a sidebar with manual, autogenerated, and collapsed groups, a custom component, an override, and a splash homepage. You end up with no astro.config.mjs or content collection to maintain.
Starlight is already an Astro docs framework, so the trade is about what you maintain. Starlight is a theme inside an Astro project you own; Blume generates and runs that project for you. If your docs are routes inside a larger Astro site, you write Markdoc, or you've overridden most of Starlight's components, staying put is probably the better call. Blume vs Starlight compares the two.
What carries over
Content and most config map across:
| In Starlight | In Blume |
|---|---|
starlight({ … }) in astro.config.mjs | blume.config.ts |
Pages in src/content/docs | The same folder, with content.root pointing there |
The sidebar array | Folders, with a meta.ts where a label or order needs it |
:::note, :::tip, :::caution, :::danger | The same asides, titles included, in .mdx pages |
<CardGrid>, <LinkCard>, <TabItem label> | <CardGroup>, <Card>, <Tab title> |
editLink and social | github, plus footer.socials |
lastUpdated: true | lastModified: "git" |
customCss | Theme settings, and a theme.css at the project root |
components overrides | Layout slots in a components.ts |
Astro's redirects and site | redirects and deployment.site |
| Pagefind search | Built-in local search, or pagefind() |
What stays Astro work
The agent converts content and config. Code that ran inside Starlight is yours to rebuild, and the agent lists each item in its summary:
- Component overrides. An override imports Starlight's components and reads
Astro.locals.starlightRoute. Neither exists in Blume, where a slot receives props, so each one is rewritten. - Splash and hero pages.
template: splashandherohave no Blume frontmatter keys. - Custom routes in
src/pages. AStarlightPageroute becomes a Blume custom page onPageLayout, read frompages/(orsrc/pageswithcontent.pagesset). - Route middleware and
headtags.routeMiddlewarehas no equivalent. Analytics scripts inheadbecomescript()adapters inanalytics; other tags are dropped. - Markdown plugins and Markdoc. Blume takes no remark or rehype plugins of your own, and it doesn't read
.mdocpages.
Plugins and integrations land here:
| Starlight plugin or integration | In Blume |
|---|---|
starlight-openapi | openapi() in reference (see the OpenAPI guide) |
starlight-versions | Native versioning, with archived versions in versions.archived |
starlight-blog | Pages with type: blog and an RSS feed, but no generated index, tag, or author pages |
starlight-links-validator, starlight-image-zoom, llms.txt plugins | Deleted: blume validate, image zoom, and llms.txt are built in |
@astrojs/starlight-docsearch | The algolia() search adapter |
@astrojs/react, @astrojs/vue, @astrojs/svelte | Nothing for React islands; keep the Vue or Svelte package installed |
| Any other Astro integration | The integrations array in blume.config.ts, installed by you |
The agent reports anything the skill doesn't map rather than guessing.
Before you start
The agent edits in place, so start on a branch with a clean working tree:
git switch -c migrate-to-blumeRun every command from the folder that holds astro.config.mjs and the package.json listing @astrojs/starlight.
Next, save the URLs your site serves today. With site set, Starlight publishes sitemap-index.xml, which points at sitemap-0.xml and any further files. Its URLs end in a slash and Blume's don't, so this strips the slash as it saves the list outside the repository:
curl -s https://docs.acme.example/sitemap-index.xml \
| grep -o '<loc>[^<]*' | sed 's#<loc>##' \
| while read -r map; do curl -s "$map"; done \
| grep -o '<loc>[^<]*' \
| sed -e 's#<loc>https://docs.acme.example##' -e 's#\(.\)/$#\1#' > ../old-urls.txtReplace docs.acme.example with your domain in both places. The sitemap leaves out redirects, so append each source path from your Astro redirects:
echo /setup >> ../old-urls.txtYou also need Node.js 22.12 or later, and Claude Code or Codex installed and signed in.
The example site
The Acme docs are a typical Starlight project:
acme-docs/
├─ astro.config.mjs
├─ package.json
└─ src/
├─ assets/logo.svg
├─ components/
│ ├─ Footer.astro overrides Starlight's page footer
│ └─ PlanLimits.astro used in an MDX page
├─ content.config.ts
├─ content/docs/
│ ├─ index.mdx template: splash
│ ├─ getting-started.md
│ ├─ guides/install.md
│ ├─ guides/send-a-message.mdx
│ ├─ reference/api-keys.md
│ └─ reference/errors.md
└─ styles/custom.cssIts config has the usual sidebar shapes, links, custom CSS, and an override:
import starlight from "@astrojs/starlight";
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://docs.acme.example",
redirects: {
"/setup": "/guides/install",
},
integrations: [
starlight({
title: "Acme",
logo: { src: "./src/assets/logo.svg" },
social: [
{ icon: "github", label: "GitHub", href: "https://github.com/acme/docs" },
{ icon: "discord", label: "Discord", href: "https://discord.gg/acme" },
],
editLink: { baseUrl: "https://github.com/acme/docs/edit/main/" },
customCss: ["./src/styles/custom.css"],
components: { Footer: "./src/components/Footer.astro" },
lastUpdated: true,
sidebar: [
{ label: "Start here", items: ["getting-started"] },
{ label: "Guides", items: [{ autogenerate: { directory: "guides" } }] },
{
label: "Reference",
collapsed: true,
items: [{ autogenerate: { directory: "reference" } }],
},
{ label: "Status", link: "https://status.acme.example" },
],
}),
],
});Starlight versions before 0.39 put autogenerate on the group itself, and that's the shape the skill describes. Both shapes convert the same way: each autogenerated group becomes a folder in the generated sidebar.
Run the migration
From the folder that holds astro.config.mjs, run:
npx blume migrate starlight --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 starlight and Blume detects the source from @astrojs/starlight in your package.json. 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 Starlight reference. The agent runs interactively, so its edits go through its usual permission prompts. Tell it up front which plugins you're keeping, and whether the repository holds anything besides the docs. Following the skill, it:
- Writes
blume.config.tsfrom thestarlight()call and the Astro config around it. - Sets
content.roottosrc/content/docs, so no page moves, and turns the sidebar into folders. - Renames pages with asides to
.mdx, maps frontmatter, swaps components, and converts code block options. - Moves the logo and images from
src/assetsintopublic/. - Removes
@astrojs/starlight, the Starlight setup, andsrc/content.config.ts, and points your scripts atblume dev,blume build, andblume preview. - Runs
blume buildandblume validateuntil both pass.
It finishes with a summary of what it migrated, dropped, and approximated. Keep it: it's your checklist for the review.
Review the config
Start with git diff --stat for the shape of the change, and run npx blume dev to click through the result. For the example, expect a config close to this, plus the Discord line you add by hand:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme",
logo: "/logo.svg",
content: { root: "src/content/docs" },
github: { owner: "acme", repo: "docs" },
footer: { socials: { discord: "https://discord.gg/acme" } },
lastModified: "git",
theme: { accent: { light: "#4f46e5", dark: "#6366f1" } },
navigation: {
featured: [
{ label: "Status", href: "https://status.acme.example", icon: "activity" },
],
sidebar: { display: "group" },
},
redirects: [{ from: "/setup", to: "/guides/install" }],
deployment: { site: "https://docs.acme.example" },
});- Repository.
githubcomes fromeditLink.baseUrl. A path after the branch becomesgithub.dir, and a GitHub Enterprise origin becomesgithub.host. - Social links. GitHub becomes the footer's repository icon. The skill drops the rest, but footer.socials covers Discord, X, Bluesky, YouTube, and more, so add yours back.
- Site and redirects. Astro's
sitebecomesdeployment.site, and itsredirectsbecome an array whosestatusdefaults to 301. - Colors. The skill moves
customCssinto a theme file, but an accent color reads better astheme.accent. Starlight's:rootis dark mode and Blume's is light mode, so this stylesheet's:rootvalue is thedarkaccent above:
:root {
--sl-color-accent: #6366f1;
}
:root[data-theme="light"] {
--sl-color-accent: #4f46e5;
}Other customCss rules move to a theme.css at the project root, rewritten against Blume's --blume-* tokens.
Review navigation
Blume builds the sidebar from folders. The example's four entries land like this:
- Guides and Reference become folder groups labeled from the folder name. A different label goes in a
meta.tstitle. - Start here wrapped one root page. Blume lists loose pages above every group, so it stays on top without the heading. A group folder keeps the heading and the URL:
(start-here)/getting-started.mdxstill serves/getting-started. - Status becomes a
navigation.featuredlink, pinned above the sidebar. - Collapsed groups. Blume's default groups are plain headings, so the example sets display: "group". Those groups start collapsed unless you're in one, the reverse of Starlight, so Guides gets a
meta.tsto stay open:
import { defineMeta } from "blume";
export default defineMeta({ collapsed: false });Check the order in each autogenerated folder too. Starlight sorts by file name, Blume by title after sidebar.order and numeric prefixes, so pin any folder that changed with sidebar.order or a meta.ts pages list. If the agent wrote an explicit navigation.sidebar to mirror the old array, delete it unless you need a shape folders can't give you: it replaces the whole generated sidebar.
Review pages
Blume renders asides only in .mdx files: in a .md page, :::note shows as literal text and the build stays green. The agent renames each page with an aside, which doesn't change its URL. This lists any it missed:
grep -rl '^:::' --include='*.md' src/content/docsBlume's frontmatter schema is strict, so Starlight-only keys change:
| Starlight frontmatter | Blume frontmatter |
|---|---|
sidebar.badge: { text, variant } | sidebar.badge, text only |
pagefind: false | search.exclude: true |
prev: false and next: false | pagination: false |
lastUpdated with a date | lastModified |
lastUpdated: false, sidebar.attrs, banner, editUrl, head, tableOfContents | Removed, with no per-page equivalent |
Components change names and props. The example's MDX page before:
---
title: Send a message
description: Send email or SMS from your code.
sidebar:
order: 2
badge:
text: Updated
variant: tip
lastUpdated: false
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
import PlanLimits from "../../../components/PlanLimits.astro";
<Tabs syncKey="lang">
<TabItem label="Node.js">
```js
await acme.messages.send({ to: "+15555550100", body: "Hello" });
```
</TabItem>
<TabItem label="Python">
```py
acme.messages.send(to="+15555550100", body="Hello")
```
</TabItem>
</Tabs>
<Aside type="note" title="Rate limits">
Each plan has a daily sending limit.
</Aside>
<PlanLimits plan="free" />And after:
---
title: Send a message
description: Send email or SMS from your code.
sidebar:
order: 2
badge: Updated
---
<Tabs syncKey="lang">
<Tab title="Node.js">
```js
await acme.messages.send({ to: "+15555550100", body: "Hello" });
```
</Tab>
<Tab title="Python">
```py
acme.messages.send(to="+15555550100", body="Hello")
```
</Tab>
</Tabs>
:::note[Rate limits]
Each plan has a daily sending limit.
:::
<PlanLimits plan="free" />Blume's components need no imports, and PlanLimits gets registered once in the next section. Elsewhere, <Badge text="New" variant="tip" /> becomes <Badge variant="accent">New</Badge>, a <Steps> list becomes <Step title="…"> children, and icons become Lucide names (setting is settings).
In code fences, title="…" and {2-3} ranges work as written, showLineNumbers becomes lineNumbers, and ins= and del= become [!code ++] and [!code --] comments. Blume also reads wrap, which the skill drops, so add it back to any block that should wrap its long lines.
Rebuild custom components
A component used in MDX
PlanLimits.astro stays in src/components. Register it in a components.ts at the project root, and every .mdx page can use it:
import { defineComponents } from "blume";
export default defineComponents({
layout: {
PageFooter: "./src/components/SupportNote.astro",
},
mdx: {
PlanLimits: "./src/components/PlanLimits.astro",
},
});The layout entry is for the override below. An interactive React, Vue, or Svelte component becomes an island instead.
A component override
The example overrides Starlight's Footer to add a support line:
---
import Default from "@astrojs/starlight/components/Footer.astro";
---
<Default><slot /></Default>
<p class="support">Need help? <a href="mailto:support@acme.example">Email support</a>.</p>Starlight's Footer holds the last-updated date, pagination, and edit link. Blume renders those itself, and its PageFooter slot below the article has no built-in to wrap, so the override shrinks to what you added:
<p class="support">
Need help? <a href="mailto:support@acme.example">Email support</a>.
</p>Blume's Footer slot is the site footer with your social icons, a different thing. Other overrides map like this:
| Starlight override | Blume slot |
|---|---|
Header, Search, Sidebar, TableOfContents, Pagination | The slot with the same name |
SiteTitle | Logo |
Footer | PageFooter |
Banner, PageTitle, Hero, EditLink, LastUpdated, SocialIcons, ThemeSelect | No slot: PageHeader and PageFooter take additions above and below the article, and blume eject hands you the whole Astro app |
Rewrite code that read Astro.locals.starlightRoute against the slot's props, listed in Layout slots.
The splash homepage
The example's homepage uses template: splash and a hero. Blume's closest layout is mode: center, with no sidebar and a wider centered column. The tagline becomes a paragraph and the button a card:
---
title: Acme docs
description: Send transactional email and SMS with the Acme Messages API.
mode: center
---
Send transactional email and SMS from one API.
<CardGroup cols={2}>
<Card title="Send your first message" icon="rocket" href="/getting-started">
Follow the getting started guide.
</Card>
<Card title="Manage API keys" icon="settings" href="/reference/api-keys">
Create and rotate keys.
</Card>
</CardGroup>For a designed landing page, build pages/index.astro on PageLayout; a custom page wins over a content page.
Check every old URL
Start npx blume dev, then walk the saved list and 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 again until it prints nothing. Three things move Starlight URLs:
- Numeric prefixes. Starlight serves
guides/01-setup.mdat/guides/01-setup/; Blume strips the prefix. Add a redirect, or setslug: guides/01-setupto keep the path. - Capitals or spaces in file names. Starlight lowercases them and hyphenates spaces; Blume keeps them. Rename the file.
- A default language in its own folder. When every language has one, the agent moves the default's (
en/) up to the content root. Set hideDefaultLocalePrefix: false to keep serving them under/en/.
The list has no trailing slashes because blume dev answers a slashed URL with a 404. Links around the web still carry the slash, so check a few on the deployed site: a vercel() server build redirects them, and other hosts differ. For status codes, patterns, and what each host does with them, see Move documentation URLs while preserving old links.
Build and validate
Stop the dev server, then build the site and check its links:
npx blume build
npx blume validate --strictblume build fails on any frontmatter, route, or config error. blume validate --strict checks internal links, anchors, and assets, failing on warnings too. Fix what they report rather than passing --no-strict, which silently drops failing pages.
Deploy and switch over
Both frameworks build into dist/, and the agent points your build script at blume build, so a host that runs npm run build and publishes dist keeps its settings, given Node.js 22.12 or later. On Vercel and Netlify, Blume detects the site URL and you can drop deployment.site. Static builds serve redirects as pages plus your host's redirect file; check what Netlify and Vercel need.
Keep the Starlight deployment until the URL check passes against the new one, so rolling back is pointing your domain back. Then submit Blume's sitemap.xml in Google Search Console, and delete any public/robots.txt naming sitemap-index.xml, since your own file replaces the one Blume writes.
Troubleshooting
A page shows :::note as text
The page is a .md file. Rename it to .mdx, where asides render.
The build fails on a page's frontmatter
BLUME_FRONTMATTER_INVALID names the file and key. The usual leftovers are template, hero, lastUpdated: false, a sidebar.badge object, and sidebar.attrs. Map each with the table above, or remove it.
A code block's header reads showLineNumbers
Blume titles a fence with the first bare word after the language, so a leftover showLineNumbers, "text" marker, or /regex/ marker on an untitled fence becomes its header. Convert or remove it.
A page warns BLUME_UNKNOWN_COMPONENT
The page uses a tag Blume doesn't know: a Starlight component such as <LinkButton> or <LinkCard>, or your own component without a components.ts entry. Convert it, or register it under mdx.
An override has no effect
Blume ignores a layout key that isn't one of its slots, without a warning. A Starlight name like SiteTitle or SocialIcons kept in components.ts does nothing; use Logo, or PageHeader and PageFooter.
A redirect fails the build
BLUME_REDIRECT_MATCHES_PAGE means a redirect pattern also covers a page you kept. Narrow it to the URLs that moved.
Next step
Migrate your docs
Run it in the folder that holds your astro.config.mjs, on a clean branch, then work through the review above.
npx blume migrate starlight --claudeA step here not working for you? Report a broken step.