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 stays inert — no React lifecycle runs through it.
Voltro implements islands per-page via the interactive export:
export const renderMode = 'static' as const
export const interactive = 'islands' as constIslands cut hydration WORK and DOWNLOAD — the page ships its own lean entry.
voltro buildemits a dedicated browser entry perinteractive: 'islands'page: react + the island runtime + exactly that page's islands — not the router, not the Effect runtime, not the subscription cache, not the app shell. Measured on the framework's reference fixture (pinned inpackages/web/bundle-budget.json, as of 2026-08-25): an islands page is 59.6 KB gzipped first-load vs 181.9 KB gzipped for the same page asfull— a factor of ~3. The bundle-budget test additionally pins a hard <70 KB bound AND the ratio (<50 % of the full page).interactive: 'none'stays at 0 B.
With interactive: 'islands', the page's HTML is server-rendered and its script tag points at the page's own entry. That entry registers the page's islands, scans for island markers, and hydrates each one on its own schedule — the page component itself never runs in the browser.
Looking for Astro's "Server Islands" — per-request-rendered holes in otherwise static pages? In Voltro that is partial prerendering (PPR): ppr = true on an isr page, a separate mechanism from islands mode. The two do not combine — ppr reveals its holes through hydration, so it requires interactive: 'full'.
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.
In each case you get back both halves: hydration work runs only inside the islands, and the download shrinks to react + the island runtime + those islands. If the page has no interactive part at all, interactive: 'none' is strictly better — it ships no JavaScript.
interactive: 'none' does not take forms with it. The strip removes every module script and modulepreload, but leaves <form> markup — and the form-flash JSON script (#__voltro_form_flash__, inert JSON, not executable code) — in place. An <AutoForm> on an interactive: 'none' page is therefore fully usable without a single byte of JavaScript: it renders action="/form/<mutationTag>" + method="post" and submits as a native form POST. Details: Forms without JavaScript.
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.
island comes from the react-only subpath @voltro/web/islands (only react + react-dom/client in its graph). Importing the @voltro/web barrel inside an island file is a BUILD ERROR — see the import rules below.
// src/components/LikeButton.island.tsx
import { island } from '@voltro/web/islands'
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 six strategies are in the table below.
Use it in a page:
// src/pages/blog/[slug]/page.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 compiles into the page's own browser entry — react + the island runtime + this page's islands (see How the per-page entry works).
- That entry 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. |
only |
Client-only: the server renders an empty placeholder, the client mounts fresh with createRoot instead of hydrating |
Components that touch window during render — chart/map libraries. |
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.
What each mode actually costs
Measured on the framework's reference web fixture (pinned in packages/web/bundle-budget.json, as of 2026-08-25) — the same page, three values of interactive, first-load JavaScript read out of the page's own built HTML (entry script + every modulepreload) and gzipped:
| Mode | JS shipped | Hydration |
|---|---|---|
interactive: 'full' |
≈181.9 KB gz — the app entry: router, Effect runtime, subscription cache, app shell | The whole page tree |
interactive: 'islands' |
≈59.6 KB gz — a per-page entry: react + the island runtime + this page's islands | Only the marked islands, each on its own strategy |
interactive: 'none' |
0 B — every module script and modulepreload is stripped from the HTML | None |
Two things to take from that table. islands IS a download optimisation now — an islands page ships roughly a third of the full page's first-load JS, because its entry carries no router, no Effect runtime, no subscription cache and no app shell. And none remains the floor: it is the only mode that removes the script tags entirely.
Both islands numbers are pinned in CI — the hard <70 KB bound and the <50 %-of-full ratio — so the gap cannot drift shut silently. Reproduce it yourself:
node packages/web/scripts/bundle-budget.mjsHow the per-page entry works
voltro build emits one browser entry per interactive: 'islands' page. The build finds the page's islands by walking the page's relative import graph for *.island.tsx files — transitively, through intermediate components. Only what is reachable from an island file ships; the page component itself may import anything, because on an islands page it never runs in the browser.
Two rules to know:
interactivemust be a source LITERAL.export const interactive = 'islands' as constselects the lean entry; a computed value does not — the page then ships the full entry as before, and the build says so loudly.- It applies on all three paths.
voltro build(SSG),voltro start(ssr/isr islands pages) andvoltro dev(the same entry mechanism, on demand) — including the import-rule violations below, which fire in dev already, not first in the build.
What an island may import (build errors, not runtime crashes)
An island file — or anything in its relative import graph — must NOT import:
@voltro/web(the barrel — router hooks,<Link>)@voltro/i18n(useT)
An island hydrates provider-less in its own root, so these hooks would throw there — and the barrel would additionally drag the Effect runtime into the lean entry. The build error names the file and the specifier.
Allowed: @voltro/web/islands, react, relative browser-safe imports — and @voltro/client / @voltro/ui (both count as framework usage and trigger the client boot below).
Framework islands: useSubscription and friends
An @voltro/client import in the island graph is DETECTED — that page's entry then boots the rpc client (a VoltroRuntimeProvider around each island root), and the island receives live data. Pages whose islands are purely presentational never pay the client core.
Limits
- An islands page reached via SPA navigation from a full page runs inside the already-loaded app bundle — the saving applies to the first visit / hard load of the islands page.
- All islands of one page share one entry (no per-island lazy chunk) — the hydrate strategies control WHEN an island hydrates, not when it loads.
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
Island props cross the boundary as JSON in an HTML attribute. The framework serialises them into the marker's data-island-props attribute + hydrates with the same values. Date arrives as an ISO string, Map/Set as {}, and functions are lost — in dev the framework warns, naming the island and the prop. Pass JSON shapes and reconstruct richer types inside the island.
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
Each islands page gets its own lean entry in .framework/dist/assets/, alongside the app entry that full pages share. Listing that directory is the whole report:
ls -l .framework/dist/assetsAll islands of one page share that page's entry — there is no per-island lazy chunk. See the mode table above for what actually reaches the browser.
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