Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
On this page

Customization

Override components, add interactive islands, mount custom pages, install registry components, or eject entirely when you need full control.

Component overrides

Add a components.ts (or components.tsx) to your project root and export defineComponents. The mdx map either replaces a built-in component or adds a new one — available in every .mdx page with no import.

import { defineComponents } from "blume";
import Callout from "./components/Callout.astro";
import Pricing from "./components/Pricing.astro";

export default defineComponents({
  mdx: {
    Callout, // replace the built-in Callout
    Pricing, // add a new <Pricing /> component
  },
});

Keys are the names you write in MDX (<Callout>, <Pricing>). Use the .tsx filename when you import React components.

A capitalized tag that’s neither a built-in, an island, nor an override fails the page when it renders, so blume dev, blume build, and blume check warn about it first as BLUME_UNKNOWN_COMPONENT, once per tag, naming every page that uses it.

Reference form

Every override — in mdx or layout — accepts three forms:

import { defineComponents } from "blume";
import Callout from "./components/Callout.astro";

export default defineComponents({
  mdx: {
    Callout, // 1. an imported component
    Note: "./components/Note.astro", // 2. a path string (resolved from the project root)
    Chart: { component: "./components/Chart.tsx", client: "load" }, // 3. a descriptor
  },
});

Blume reads components.ts statically — it never runs the file — so these three forms are the only ones it accepts. An inline function or expression, a component declared in the file itself, a spread, a computed key, or a client that isn’t one of the mode strings below is a BLUME_COMPONENTS_INVALID error that names the entry: blume dev reports it in the terminal and the browser overlay, and blume build fails.

The descriptor form adds a hydration mode so an interactive React/Vue/Svelte component ships its JavaScript and comes alive on the client. Without a client mode a framework component renders as static HTML — Blume prints a build warning when it spots one, since that’s usually a mistake.

client Hydrates
"load" Immediately on page load
"idle" When the main thread is idle
"visible" When scrolled into view
"media" When a media query matches (add media: "(min-width: 40rem)")
"only" Client only, never server-rendered

An mdx entry with a client mode is an island: it’s available in every page and hydrates exactly like a component dropped in the islands/ folder. The folder stays the zero-config path — reach for a components.ts entry when you want a different name than the file, a media query, or to keep islands beside your other overrides.

Typing an override

When you replace a built-in, import its prop type from blume/components so your component matches the contract — the types are derived from the components themselves, so they never drift:

import type { CalloutProps } from "blume/components";

export default function Callout(props: CalloutProps) {
  // …your own callout, same props as the built-in
}

Prop types are exported for the content components (CalloutProps, CardProps, TabsProps, StepsProps, BadgeProps, and more).

Layout slots

The layout map replaces a piece of Blume’s chrome with your own component. Each override receives the same props as the built-in it replaces, so you can wrap the default or start from scratch.

import { defineComponents } from "blume";
import Footer from "./components/Footer.astro";
import Logo from "./components/Logo.astro";

export default defineComponents({
  layout: {
    Logo, // brand mark + title in the header
    Footer, // site-wide footer, in place of the one `footer` configures
  },
});

Wired slots (names are case-sensitive; any other key under layout renders nowhere, and Blume warns about it):

Slot Replaces Props
Layout The entire page shell (RootLayout) Everything the built-in layout receives, plus the layout map
Header The top navigation bar site, logo, navigation, route, searchEnabled, …
Logo The brand link (mark + title) in the header site, logo, locale
Search The header search trigger + modal navigation, strings, locale, assistantEnabled
Sidebar The primary navigation tree items, currentRoute
MobileNav The nav inside the mobile drawer (defaults to Sidebar) items, currentRoute
Breadcrumbs The breadcrumb trail crumbs
TableOfContents The on-this-page outline, in the right rail (variant: "desktop") and in the dropdown above the content on narrower screens ("mobile") headings, title, variant
Pagination The prev/next footer links prev, next, strings
Feedback The “Was this page helpful?” rating below the article (rendered only when feedback is on) strings, comments
PageHeader An injection point above the article (no built-in) page, headings, route
PageFooter An injection point below the article (no built-in) page, headings, route
Footer The site footer after the content grid: links, social profiles, the repository link, and Cookie settings footer, locale, site, navigation, ui

The mobile variant also receives the page actions as its children (an empty element the actions move into from the right rail below 1,280px), so render a <slot /> in it to keep them on narrower screens. It renders on a page with no headings as well, with headings empty, so the actions still show there.

PageHeader and PageFooter have no built-in component — they render nothing until you set them, which makes them handy injection points for a promo banner or a “last updated” note. PageFooter renders at the foot of every page Blume builds from your content, API operation pages included, with Feedback and Pagination after it. A page in custom or frame mode drops that page-end area along with its title and breadcrumbs. A custom page under pages/ renders the layout it wraps itself in: RootLayout takes slot overrides through its layout prop, and PageLayout has no page-end area. The built-in Footer holds the only links to your repository and social profiles, so a Footer override (the place for a marketing footer the config can’t express) should keep them. With consent set, also keep a way to reopen it: any element with data-blume-consent-open works as the Cookie settings link.

Layout slots accept the same three reference forms as MDX overrides, so a slot can be a path string or a hydrated descriptor ({ component, client }) when you want an interactive header or footer.

Interactive islands

For interactive UI (React, Vue, or Svelte), drop a component into an islands/ folder and use it in any MDX page — Blume hydrates it for you, no wrapper or registration needed:

import { useState } from "react";

export default function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>Clicked {n}</button>;
}
Use it anywhere: <Counter />

See Islands for hydration strategies and framework setup.

Custom pages

Add .astro files under your pages/ folder to mount fully custom routes alongside your docs — a landing page, a pricing page, or a hand-built index. They keep their location, so relative imports and getStaticPaths work as usual, and they can read your config, navigation, and routes from the blume:data module.

See Custom pages for the full guide.

Registry

blume add copies a Blume-maintained component into your project as source — you own it and can edit it freely. Run it with no arguments to list what’s available:

blume add

Install a layout slot (header, sidebar, breadcrumbs, table of contents, pagination, or feedback) or any content component (callout, card, tabs, steps, accordion, and more):

blume add callout
blume add pagination

The copy imports the rest of the framework from blume/*, so it renders exactly like the built-in until you change it. blume add prints the defineComponents snippet to register it — content components under mdx, layout pieces under layout.

Astro integrations

Add any Astro integration from the top-level integrations array in blume.config.ts. Install the integration in your site first; Blume does not add it to the generated runtime’s dependencies or manage its Astro compatibility.

npm install @astrojs/partytown
import partytown from "@astrojs/partytown";
import { defineConfig } from "blume";

export default defineConfig({
  integrations: [
    partytown({
      config: { forward: ["dataLayer.push"] },
    }),
  ],
});

Blume keeps its built-in integrations in their existing order, then appends your entries in declaration order. It does not sort or deduplicate them, so two integrations with the same name both run. Blume validates that integrations is an array, while Astro validates each entry and reports invalid integrations.

Because Blume loads your integrations by re-importing blume.config.ts from the generated Astro config rather than copying the instances, the config module evaluates twice per run — once when Blume reads your config and once when Astro loads it. Keep integration factories side-effect free (return the integration; don’t write files or open connections at construction) so the second evaluation is harmless.

The same integrations run in blume dev and blume build. Editing blume.config.ts during blume dev regenerates the hidden Astro config and triggers a config restart; if you don’t see an edited integration take effect, restart blume dev. Blume can’t tell which config edits affect integrations, so once integrations is non-empty, every edit to blume.config.ts — even to an unrelated field — restarts the dev server rather than hot-applying. Blume only tracks the contents of blume.config.ts, so editing a separate file it imports won’t trigger that regeneration on its own — restart blume dev after such edits. If you eject, the owned astro.config.mjs keeps a relative bridge to blume.config.ts, so the configured integrations continue to run; you can later move them directly into the Astro config as part of taking full ownership.

Eject

When you want full control, eject the generated runtime into a standalone Astro project:

npx blume eject --yes

Eject is a one-way step: the hidden .blume/ runtime becomes a normal Astro app you own and can modify directly. The blume package stays importable, so you keep its components, theme, and Markdown processors.

Eject points your package.json scripts at astro dev and astro build, and adds the packages the Astro app imports by name — astro, @tailwindcss/vite, tailwindcss and @tailwindcss/typography for the generated stylesheets, the search client, integrations, and adapter the config wires in, React when an island, example, or the assistant uses it, ai for the assistant route, and epub-gen-memory for EPUB export — at the ranges Blume itself uses. Run an install before dev or build; eject lists what it added. Every path in the ejected app is relative, so it builds from any checkout, CI included.

From then on, run the app through its own scripts (npm run dev, npm run build): blume dev, blume build, blume check, blume sync, and blume preview stop in an ejected project and point you there. Running blume eject again refuses too, since it would overwrite your edits; pass --force to regenerate the app anyway.

What eject keeps

The routes the hidden runtime serves as pages are written into the app’s src/pages, so it answers the same URLs: each page’s Markdown twin, the 404 page with its /404.md and /404.json twins, the hosted MCP server, and the JSON docs API at /api/docs/… and /openapi.json that llms.txt and the not-found page point agents to.

The ejected app’s build script runs plain astro build, and the artifacts blume build layers on top — the search index (and a hosted provider’s index sync), llms.txt and llms-full.txt, sitemap.xml, robots.txt, agent-readability.json, the .well-known discovery files, Agent Skills, and the platform _redirects/_headers files — are still produced: the Blume integration in the ejected astro.config.mjs writes them from Astro’s astro:build:done hook, scanning the project (your blume.config.ts and content) the way the CLI did. What the ejected build does not do is the CLI’s adapter post-processing: the Vercel and Cloudflare Accept: text/markdown routing splices, the Vercel function-bundle audit, the node() server-entry wrapper (the .well-known discovery files’ media types and CORS headers, the sandbox on downloaded SVGs, and each redirect’s exact status), the header rules a netlify() server build writes into .netlify/v1/config.json, Cloudflare’s Worker naming (after your project) and its .wrangler/deploy redirect for running wrangler deploy from the project root, and the --analyze/--budget-* gate.

Last updated on

Was this page helpful?