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 const

Islands cut hydration WORK and DOWNLOAD — the page ships its own lean entry.

voltro build emits a dedicated browser entry per interactive: '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 in packages/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 as full — 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 same island() 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:

  1. 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>
  2. 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).
  3. That entry scans for [data-voltro-island] markers, looks each name up in its registry, and hydrates that <div> per its data-island-hydrate strategy.

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.mjs

How 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:

  • interactive must be a source LITERAL. export const interactive = 'islands' as const selects 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) and voltro 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-local

If 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/assets

All 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 useLoaderData inside an island. Loaders run for the PAGE, not islands. Islands receive props from the page; the page reads loader data.