Upgrade to Blume 2
Move a Blume 1 site to Blume 2 with one command, then use this guide for each config change — or hand the whole upgrade to Codex or Claude Code.
Blume 2 changes configuration, not content: your Markdown and MDX pages need no edits (see Frontmatter for one field that now does something). Settings that used to be a named string or a keyed block — the search provider, the deployment target, content sources, API references, analytics, and the assistant’s model backend — are now adapters you import from a blume/* subpath and call. Ask AI is renamed the assistant, so ai.ask becomes ai.assistant. The machine-readable settings move from ai to a new agents key, and components.ts overrides are checked before the build. A zero-config site, or one that sets none of these, only needs the version bump.
Upgrade with one command
Run the upgrade from your project, the folder with blume.config.ts:
npx blume@latest upgradepnpm dlx blume@latest upgradeyarn dlx blume@latest upgradebunx blume@latest upgradenubx blume@latest upgradeaube dlx blume@latest upgradeIt bumps blume in your package.json to 2, installs it with the package manager your project uses, then checks your config and components.ts against Blume 2. Every change that’s still needed is listed with its file, line, and replacement — including package.json scripts that still pass the removed blume build flags — and the command exits non-zero until none are left. Run from a folder with neither a config nor a blume dependency, it stops with an error instead. Run it through npx blume@latest rather than blume: the command ships in Blume 2, so a project still on 1 doesn’t have it yet. On pnpm 12, add --allow-build=esbuild after pnpm dlx, since pnpm 12 won’t run esbuild’s install script unapproved.
To hand the changes to a coding agent instead, add --codex or --claude:
npx blume@latest upgrade --codexpnpm dlx blume@latest upgrade --codexyarn dlx blume@latest upgrade --codexbunx blume@latest upgrade --codexnubx blume@latest upgrade --codexaube dlx blume@latest upgrade --codexThe agent opens interactively with the findings and this guide, applies each change, and runs blume doctor and blume build until both pass, so you review every edit through its own permission flow. Pass --no-install to bump package.json without installing.
The sections below cover each change, for upgrading by hand or checking what the agent did.
Search
search takes an adapter from blume/search instead of a provider string with a credentials block. The default local search needs no change.
export default defineConfig({
search: {
provider: "algolia",
algolia: { appId: "APP_ID", indexName: "docs", searchApiKey: "SEARCH_KEY" },
},
});import { defineConfig } from "blume";
import { algolia } from "blume/search";
export default defineConfig({
search: algolia({ appId: "APP_ID", indexName: "docs", apiKey: "SEARCH_KEY" }),
});- Orama Cloud, Typesense, and Mixedbread map the same way to
oramaCloud(),typesense(), andmixedbread(). The search-only key isapiKeyin every adapter that takes one;mixedbread()takesstoreIdrather than a key, since its queries run on the docs server; any other option it’s given is forwarded to the store search call, wheretop_kdefaults to 8. Admin keys stay in their env vars (ALGOLIA_ADMIN_API_KEY,ORAMA_PRIVATE_API_KEY,TYPESENSE_ADMIN_API_KEY,MIXEDBREAD_API_KEY). provider: "pagefind"becomespagefind(), andprovider: "none"becomessearch: false.- To keep
popularlinks orindexingoptions, wrap the adapter:search: { provider: algolia({ … }), popular: […] }.
Deployment
deployment takes a host adapter from blume/deploy instead of adapter and output fields. site and base move into the adapter’s options.
export default defineConfig({
deployment: {
adapter: "vercel",
output: "server",
site: "https://docs.example.com",
},
});import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
export default defineConfig({
deployment: vercel({ site: "https://docs.example.com" }),
});netlify(),cloudflare(), andnode()work the same way. Naming a host adapter switches to server output; passoutput: "static"to keep a static build with that host’s platform files.- A config that only sets
siteorbasestays as it is:deployment: { site, base }is still the static form. redirectsread:namesegments and a trailing*as patterns, matched the same way on every host (see Pattern redirects). Blume 1 never supported patterns and handed them to each host as written, so check anyfromortothat holds one.- The
--adapter,--output, and--baseflags onblume buildare gone, and passing one stops the build with an error naming thedeploymentsetting that replaces it. Set the adapter inblume.config.ts, and name it explicitly: server output is no longer inferred from the platform’s environment. A CI step that passed a different--baseper deploy can read it from an environment variable in the config instead.
See Deployment for each adapter’s options.
Content sources
Each content.sources entry is an adapter from blume/sources instead of a { type } object.
export default defineConfig({
content: {
sources: [
{ type: "filesystem", root: "content" },
{
type: "github-releases",
owner: "acme",
repo: "sdk",
prefix: "changelog",
},
],
},
});import { defineConfig } from "blume";
import { filesystem, githubReleases } from "blume/sources";
export default defineConfig({
content: {
sources: [
filesystem({ root: "content" }),
githubReleases({ owner: "acme", repo: "sdk", prefix: "changelog" }),
],
},
});mdx-remote,sanity,notion, andobsidianbecomemdxRemote(),sanity(),notion(), andobsidian(), with every other field moving into the call unchanged.{ type: "custom", source }becomescustom(source).content.root,content.include, andcontent.excludeare still the shorthand for a single folder, but they can’t sit besidesourcesany more. Move them into thefilesystem()entry.- Release pages from
githubReleases()publish in one language now, so a multi-locale site no longer copies them to every other locale’s URL (/de/changelog/…). If other sites link to those copies, add redirects to the default-locale pages. - Text typed in Notion renders as written now: a component,
{, or Markdown character in anotion()page shows as typed instead of being read as MDX. If your Notion pages hold components or Markdown typed as text, setmdx: trueon the source.
API references
The top-level openapi, asyncapi, and graphql blocks become one reference list of adapters from blume/reference. Drop enabled.
export default defineConfig({
openapi: { enabled: true, spec: "./openapi.yaml" },
graphql: {
enabled: true,
spec: "./schema.graphql",
endpoint: "https://api.example.com/graphql",
},
});import { defineConfig } from "blume";
import { graphql, openapi } from "blume/reference";
export default defineConfig({
reference: [
openapi({ spec: "./openapi.yaml" }),
graphql({
spec: "./schema.graphql",
endpoint: "https://api.example.com/graphql",
}),
],
});asyncapi: { … }becomesasyncapi({ … })with the same options.- An AsyncAPI 1.x or 2.x spec is still converted to 3.0 for you, but the converter is now an optional peer: install
@asyncapi/converterin your project, or the build fails with the install command. A 3.x spec needs nothing. renderer: "scalar"becomes its ownscalar({ spec, theme, … })entry in the list, keeping the block’srouteandsources.- A block with
enabled: falseis simply left out of the list.
Analytics
The analytics object becomes a list of adapters from blume/analytics.
export default defineConfig({
analytics: {
posthog: { key: "phc_…" },
vercel: true,
},
});import { defineConfig } from "blume";
import { posthog, vercel } from "blume/analytics";
export default defineConfig({
analytics: [posthog({ key: "phc_…" }), vercel()],
});cloudflare: { token } becomes cloudflare({ token }), and each scripts[] entry becomes script({ … }).
Assistant
Ask AI is now called the assistant, and its config moves with the name: ai.ask becomes ai.assistant. Its provider takes an adapter from blume/ai, which owns the model and the fields that went with it.
export default defineConfig({
ai: {
ask: {
enabled: true,
provider: "openrouter",
model: "anthropic/claude-sonnet-4-5",
reasoning: "none",
},
},
});import { defineConfig } from "blume";
import { openrouter } from "blume/ai";
export default defineConfig({
ai: {
assistant: {
enabled: true,
provider: openrouter({
model: "anthropic/claude-sonnet-4-5",
reasoning: "none",
}),
},
},
});A 1.x provider name maps onto gateway(), openrouter(), llmgateway(), or inkeep(), and openai-compatible onto openai({ baseUrl, name, model, apiKeyEnv }); the adapter list has the rest. model, apiKeyEnv, baseUrl, headers, and reasoning move into the adapter; enabled, instructions, retrieval, suggestions, cors, and endpoint move to ai.assistant unchanged. Leaving provider unset still uses the AI Gateway.
The rename reaches every name that said Ask AI:
i18n.uioverrides: theaskgroup becomesassistant, andsearch.askAiandsearch.askAiHintbecomesearch.assistantandsearch.assistantHint.useAskAIfromblume/hooksbecomesuseAssistant, with itsUseAssistantandUseAssistantOptionstypes.- The
askEnabledprop onPageLayout,RootLayout,Header, and aSearchoverride becomesassistantEnabled. - In the
blume:datamodule,config.askbecomesconfig.assistantandui.askbecomesui.assistant, and theUIStringstype fromblumefollows. - The adapter types from
blume/aiswap theirAskprefix forAssistant(AskAdapterbecomesAssistantAdapter,AskGatewayOptionsbecomesAssistantGatewayOptions), andaskReasoningLevelsandAskReasoningfromblume/schemabecomeassistantReasoningLevelsandAssistantReasoning. - The
blume:open-ask-aiwindow event becomesblume:open-assistant, and thedata-blume-askattribute on<body>becomesdata-blume-assistant.
The generated /api/ask route and the ask, ask_answer, and ask_error analytics events keep their names, so callers and dashboards need no change. blume upgrade and blume doctor name each old config key they find, ai.ask and the i18n.ui keys alike, with its replacement.
Upgraded to Blume 2.0.0 already? It still read ai.ask, so update blume as usual and make the same rename.
Agents and other config moves
The machine-readable settings move from ai to a new agents key, and three smaller fields change shape.
export default defineConfig({
ai: { mcp: { enabled: true }, skills: "./skills" },
lastModified: true,
markdown: {
codeBlocks: { theme: { light: "github-light", dark: "github-dark" } },
},
theme: { layout: "sidebar" },
});export default defineConfig({
agents: { mcp: { enabled: true }, skills: "./skills" },
lastModified: "git",
markdown: {
code: { theme: { light: "github-light", dark: "github-dark" } },
},
});ai.api,ai.catalog,ai.llmsTxt,ai.markdownComponents,ai.mcp,ai.skills,ai.webBotAuth, andai.webmcpbecomeagents.*, and so doseo.agentReadabilityandseo.contentSignals.aikeeps onlyassistant(formerlyask) andopenInChat.lastModifiedis a flat value:truebecomes"git", and{ type: "git" }or{ type: "frontmatter" }becomes the bare string.markdown.codeBlocksmerges intomarkdown.code.theme.layoutis gone. Nothing read it, so delete it.
Frontmatter
Pages keep their frontmatter. One field changes what it does: search.boost. Blume 1 accepted it, but search never read it, so a page ranked the same without it. It now multiplies the page’s search relevance (see Ranking). Check any page that sets it: a boost you added and forgot now moves the page up.
Component overrides
Blume 2 checks every components.ts entry before the build instead of falling back at runtime. Each mdx and layout entry must be an imported component, a path string, or a { component, client, media } object, and the islands group is gone: an mdx entry with a client mode is an island.
import { defineComponents } from "blume";
import Counter from "./islands/Counter.tsx";
export default defineComponents({
islands: { Counter },
});import { defineComponents } from "blume";
import Counter from "./islands/Counter.tsx";
export default defineComponents({
mdx: { Counter: { component: Counter, client: "visible" } },
});An inline function, a component declared in components.ts itself, a spread, or a computed key now fails with BLUME_COMPONENTS_INVALID, naming the entry. Move the component into its own file and import it. The islands/ folder convention works as before. See Customization for the accepted forms.
Ejected apps
An app you ejected on Blume 1 no longer runs through the Blume CLI, but it still depends on the blume package. Its pages import Blume’s components, src/generated/ holds a snapshot of your site written by the Blume 1 generator, and astro build loads blume.config.ts again to write the search index, llms.txt, and the sitemap. Bumping blume to 2 under it pairs that Blume 1 snapshot with Blume 2 components that expect the new shapes, so eject again instead:
Copy the project out
Copy everything except astro.config.mjs, src/, .blume/, dist/, and
node_modules/ into an empty folder: your content, blume.config.ts,
components.ts, islands/, public/, any spec files your references read,
and package.json. Leave the ejected app as it is.
Upgrade the copy
In the copy, run npx blume@latest upgrade and apply what it lists, so
blume.config.ts and components.ts are valid Blume 2. Then run npx blume build to confirm the site builds before you eject it.
Eject a fresh copy
Run npx blume eject --yes in the copy, install the packages it adds, and
build it with npm run build.
Carry your edits across
Diff the fresh astro.config.mjs and src/ against your ejected app, and
move your own changes onto the new files.
Until the fresh copy is ready, keep the ejected app on Blume 1 ("blume": "^1") and don’t run blume upgrade in it: nothing changes until you bump it.
Command-line flags
Every blume command now rejects a flag it doesn’t take, where Blume 1 ignored it. A script or CI step that passes a stray or misspelled flag fails, naming the flag it didn’t recognize and the ones the command accepts. blume upgrade reports only the three removed blume build flags (--adapter, --output, --base), so check your other blume scripts too.
Check your work
Once blume upgrade reports nothing left to change, run the site’s own checks:
npx blume doctor
npx blume build
The full list of changes, with the reasoning behind each, is in the changelog.