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 const

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

  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 bundles into the client chunk.
  3. The client runtime 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.
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-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

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 KB

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