Islands
interactive: 'islands' — ship pure HTML with selectively-hydrated interactive components.
The islands model: serve the page as pure HTML, then hydrate only the bits that need interactivity. The rest of the page is just text + CSS — no React runtime, no JS bundle, no hydration cost.
Voltro implements islands per-page via the interactive export:
export const renderMode = 'static' as const
export const interactive = 'islands' as constWith interactive: 'islands', the framework strips the page's React runtime from the HTML, but loads *.island.tsx files as separate chunks + hydrates them in place.
When to use islands
- Marketing pages with one interactive widget (a pricing toggle, a code playground).
- Docs that are mostly text but have a search modal + theme toggle.
- Blog posts with an embedded poll or comment widget.
The split lets you ship near-zero JS for the body + JS only for the islands. Lighthouse scores stay high; React's overhead applies only to the interactive parts.
Writing an island
Wrap a component with island(Component, { name, hydrate }) and default-export the result. The plain component is NOT enough — without the island() call the component is never registered, and at hydration time the runtime logs island "…" not registered.
// src/components/LikeButton.island.tsx
import { island } from '@voltro/web'
import { useState } from 'react'
const LikeButton = ({ initial }: { initial: number }) => {
const [count, setCount] = useState(initial)
return (
<button onClick={() => setCount((n) => n + 1)}>
❤ {count}
</button>
)
}
export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })name— the stable id under which the component is registered. Must be unique within the app. Both the SSR and the client bundle import the file, so the sameisland()call runs on both sides and registers the component in each.hydrate— when the client runtime should hydrate this island (defaults to'visible'). The five strategies are in the table below.
Use it in a page:
// src/pages/blog/[slug].tsx
import LikeButton from '../../components/LikeButton.island'
export const renderMode = 'static' as const
export const interactive = 'islands' as const
export default function Post() {
return (
<article>
<h1>Post title</h1>
<p>…body content…</p>
<LikeButton initial={42} />
</article>
)
}What happens at build:
- The page is server-rendered to HTML. The
island()wrapper emits a marker<div>carrying the name, props, and hydrate strategy:<div data-voltro-island data-island-name="LikeButton" data-island-hydrate="visible" data-island-props='{"initial":42}'> <button>❤ 42</button> </div> - The island bundles into the client chunk.
- The client runtime scans for
[data-voltro-island]markers, looks each name up in its registry, and hydrates that<div>per itsdata-island-hydratestrategy.
The rest of the page stays as inert HTML.
Hydrate strategies
Each island declares WHEN it hydrates via the hydrate option (default 'visible'):
| Strategy | When the island hydrates | Use for |
|---|---|---|
load |
Immediately, as soon as the client runtime mounts | Above-the-fold widgets users touch within the first second — search box, primary CTA. |
idle |
When the browser is idle (requestIdleCallback, setTimeout fallback) |
Important widgets that don't need instant interactivity — analytics, secondary nav. |
visible (default) |
When the element scrolls into the viewport (IntersectionObserver) | Anything below the fold — comment box, related-articles carousel. |
interaction |
On the first pointer / keyboard event on the element | Heavy widgets users might touch — embedded playground, deep tree viewer. Defers cost until commitment. |
never |
Never — the server-rendered HTML stays inert | Server-only displays that never change after SSR (a build-time status badge). |
Mix freely inside one page: a load search box, a visible comment widget, and a never build banner can all coexist.
Bundle savings
For a typical Voltro page:
| Mode | JS shipped |
|---|---|
interactive: 'full' |
Full React + page bundle (~80-150 KB gzipped) |
interactive: 'islands' |
Island boot loader + island chunks (typically 10-30 KB gzipped) |
interactive: 'none' |
Zero |
For pages where the body never moves, the savings are dramatic.
Island boundaries
The island component owns its sub-tree's interactivity. Inside an island, you can:
useState,useEffect, every React hook- Import + use any other component
- Render JSX freely
What you CAN'T do:
- Make the parent page interactive from inside. The island can't trigger a page-level re-render.
- Read from React Context defined in the page. Each island has its own React root.
- Share state across islands directly. Use the URL,
localStorage, or a custom message channel.
Each island is independent — there is no shared React root across islands. To coordinate, use the URL, localStorage, or a custom message channel.
Props serialisation
Props passed to an island must be JSON-serialisable. The framework serialises them into the marker's data-island-props attribute + hydrates with the same values.
OK:
<LikeButton initial={42} kind="heart" tags={['blog']} />NOT OK:
<LikeButton onClick={() => …} /> // functions can't serialise
<LikeButton date={new Date()} /> // Date → string; use ISO + parse inside
<LikeButton ref={someRef} /> // refs are component-localIf you need to pass a function reference, define it INSIDE the island.
When NOT to use islands
- Whole page is interactive. Use
interactive: 'full'— you'd just be adding the island boot overhead for no benefit. - Islands that share state. Each island is its own root — two islands talking is painful. Coordinate via the URL,
localStorage, or a message channel. - Islands that hydrate immediately and dominate the page weight. If the island is the whole page minus a header, just go
interactive: 'full'.
Combining with render modes
renderMode × interactive |
Use case |
|---|---|
static + islands |
Marketing landing with a pricing toggle |
static + none |
Pure-content blog posts |
static + full |
SPA-like docs sites |
ssr + islands |
Personalised pages with a few interactive widgets |
isr + islands |
High-traffic listings with a "like" button |
Inspecting
The build pipeline reports which chunks are islands:
[voltro build] vite build…
chunks emitted:
main.abc.js 145 KB ← page runtime (full pages only)
island-LikeButton.def.js 3.4 KB ← per-island bundle
island-Search.ghi.js 8.1 KBInspect dashboard's "Bundles" panel shows the per-island size + hydration timings.
Anti-patterns
- Wrapping everything in one big island. Defeats the purpose — you've just rebuilt full hydration with extra steps.
- Passing 100 KB of JSON as island props. The serialised payload ends up in the HTML — pretty quickly an island's "props" cost dwarfs the saved bundle.
- Calling
useLoaderDatainside an island. Loaders run for the PAGE, not islands. Islands receive props from the page; the page reads loader data.
Where to read next
- Render modes — pairs with
interactive - Navigation — Link + prefetch work the same on islands pages