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

Islands

Drop an interactive component into islands/ and use it in any MDX page — hydrated automatically, no per-page import.

Blume renders your docs as static HTML with zero JavaScript by default. When you need something interactive — a live demo, a chart, a playground — you add an island: a framework component that ships JS only for itself, only on the pages that use it.

The islands/ convention

Drop a component into an islands/ folder at your project root. Its filename becomes a component you can use in any .mdx page, with no import:

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>Clicked {count}</button>;
}
Here's a live counter: <Counter />

The filename is the component name, so it must be a PascalCase identifier — letters, digits, and underscores only (Counter.tsx → <Counter />). Lowercase filenames and names with dashes/dots/spaces (like Time-Picker.tsx) are skipped with a build warning. When two islands resolve to the same name — Counter.tsx and Counter.vue, or two Counter.tsx files in different subfolders — Blume keeps the first by file path and ignores the other, with a build warning.

An island is the only way to run code in the browser from a page. A JavaScript event handler on a plain HTML element in MDX, like <button onClick={() => open()}>, never runs: the page is static HTML, so the function is written into the attribute as text. Blume warns about one as BLUME_MDX_EVENT_HANDLER at its line. Move that markup into an island and use the island in the page.

Registering islands in components.ts

If you’d rather keep islands next to the rest of your components — or give them a different name than the file — register them with defineComponents as an mdx entry with a client mode. That’s all an island is: an MDX component that hydrates. Each entry is available in every MDX page, and an entry with the same name as an islands/ file replaces it.

import { defineComponents } from "blume";
import Counter from "./widgets/Counter.tsx";

export default defineComponents({
  mdx: {
    Counter: { component: Counter, client: "visible" }, // <Counter /> in any MDX page, hydrated
  },
});

Reference the component by import or by a path string, and pick the hydration mode per island ("media" takes a media query alongside it):

export default defineComponents({
  mdx: {
    Chart: { component: "./widgets/Chart.tsx", client: "only" },
  },
});

Unlike the folder, there’s no default: an mdx entry without client renders as static HTML, and Blume warns when that entry is a React, Vue, or Svelte component. Blume reads components.ts statically, so an entry must be an imported component, a path string, or a { component, client, media } object literal — see Customization for the accepted forms and the error you get otherwise.

Hydration

By default an island uses client:visible: it hydrates when the reader scrolls it into view, so a page full of islands still loads instantly. Opt into a different strategy with an export const client in the island file:

// Skip server rendering entirely — for components that touch the DOM/window.
export const client = "only";

export default function Chart() {
  /* ... */
}
client value Hydrates Use it for
"visible" (default) When scrolled into view Most islands
"load" Immediately on page load Above-the-fold, must-be-instant UI
"idle" When the main thread is idle Non-urgent interactivity
"only" Client only, never server-rendered Libraries that need window/document (charts, editors)
"media" (components.ts only) When a CSS media query matches UI that only runs at some sizes, like a mobile-only menu

"media" needs the query beside it, so it’s available only to a components.ts entry — { component: Menu, client: "media", media: "(max-width: 50em)" }. An islands/ file that declares it falls back to "visible" with a warning.

Frameworks

React works out of the box — Blume turns it on automatically the moment your project contains a .tsx/.jsx island.

The React Compiler is on by default whenever React is enabled, so your islands are auto-memoized — no hand-written useMemo/useCallback needed. It ships with Blume; there’s nothing to install. Opt out in blume.config.ts:

export default defineConfig({
  react: { compiler: false },
});

Vue and Svelte are supported too; install the matching Astro integration and Blume wires up the renderer when it sees a .vue or .svelte island:

# Vue
npm install @astrojs/vue vue

# Svelte
npm install @astrojs/svelte svelte
<script setup>
import { ref } from "vue";
const on = ref(false);
</script>

<template>
  <button @click="on = !on">{{ on ? "On" : "Off" }}</button>
</template>

Props you pass in MDX (<Counter start={5} />) are forwarded to the component, and children (<Counter>label</Counter>) arrive as the default slot.

Hooks

Islands hydrate on their own, so there’s no React context to thread project data through. Instead, blume/hooks reads a small snapshot the layout serializes into the page — no props to drill:

import { useBlume, usePage } from "blume/hooks";

export default function PageInfo() {
  const blume = useBlume();
  const page = usePage();
  if (!(blume && page)) {
    return null;
  }
  return (
    <p>
      You're reading <strong>{page.title}</strong> on {blume.config.title}.
    </p>
  );
}
Hook Returns
useBlume() { config, navigation } for the site, or null before mount
usePage() { route, title } for the current page, or null before mount
useSearch() { search, results, loading } — query the configured search provider
useAssistant() { ask, messages, loading, reset } — stream from the assistant endpoint

useBlume() and usePage() return null until the island mounts (so server and client render the same first frame) — guard for it. The snapshot is emitted only on pages that ship React, so a fully static site pays nothing.

On custom pages built with PageLayout, pass clientData so islands there can read it:

<PageLayout
  clientData={{ config: data.config, navigation: data.navigation, page: { route: "/", title: "Home" } }}
  {/* …other props… */}
/>

Last updated on

Was this page helpful?