zfb
GitHub repository

Type to search...

to open search from anywhere

Islands

Created Jun 24, 2026Takeshi Takatsudo

Mark client-interactive components with "use client" and let zfb hydrate them in the browser.

zfb pages render to static HTML by default. Islands are the escape hatch — small components that ship JavaScript to the browser and hydrate on the client, while the rest of the page stays as plain HTML.

The mental model is straightforward: most of your page is a static document. A few interactive bits — a counter, a search box, a theme toggle — are islands embedded inside that document, hydrated in the browser from a single shared bundle.

What an island is

Add the "use client" directive at the top of a .tsx file:

"use client";

import { useState } from "preact/hooks";

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

That single directive is the whole opt-in. Files without it are pure server components — they render once at build time and never reach the browser.

The theme-toggle.tsx component from the zfb-example-blog standalone repo is the canonical real-world example: it reads localStorage and matchMedia, manages its own state, and mirrors the active theme to document.documentElement.dataset.theme. The key pattern is that it renders a deterministic SSR-safe default on first paint, then syncs to the user preference inside useEffect:

"use client";

import { useEffect, useState } from "preact/hooks";

type Theme = "light" | "dark";

export default function ThemeToggle() {
  // Deterministic SSR-safe default. Real preference is applied in useEffect.
  const [theme, setTheme] = useState<Theme>("light");

  useEffect(() => {
    const saved = window.localStorage.getItem("theme");
    if (saved === "light" || saved === "dark") setTheme(saved);
  }, []);

  const next: Theme = theme === "dark" ? "light" : "dark";
  return (
    <button
      type="button"
      aria-pressed={theme === "dark"}
      onClick={() => setTheme(next)}
    >
      {theme === "dark" ? "Light mode" : "Dark mode"}
    </button>
  );
}

What esbuild produces for N islands

For a project with three islands (Counter, ThemeToggle, SearchBox), the islands build step emits one shared bundle:

dist/assets/islands.js

All islands are statically imported into a single esbuild entry point, so the shared bundle contains every island component. ProductionAssetPipeline then renames it to a content-hashed filename before writing the final output:

dist/assets/islands-<hash>.js

If any island uses a dynamic import() internally, esbuild may also emit code-split chunks alongside it:

dist/assets/islands-chunk-<hash>.js

The bundle registers each island by its static marker name and calls mountIslands() at the end of the module to hydrate every [data-zfb-island] element on the page.

The ProductionAssetPipeline is the single source of truth for hashing — the bundler writes a stable islands.js first, and the pipeline performs the hash-rename and rewrites the injected script URL in the emitted HTML. Nothing downstream has to guess the filename.

How islands are loaded

After render, each island's server-rendered HTML is wrapped in a <div> that carries metadata:

<div data-zfb-island="ThemeToggle"
     data-props="{}">
  <!-- server-rendered island HTML -->
  <button type="button" aria-pressed="false">Dark mode</button>
</div>

The data-when attribute controls hydration timing and is only emitted when you request a non-default timing. The "load" timing (hydrate immediately) is the default and produces no data-when attribute. The "visible", "idle", and "media" strategies each emit a data-when attribute; "media" also emits a companion data-media attribute carrying the CSS query string:

<!-- visible timing -->
<div data-zfb-island="SearchBox" data-props="{}" data-when="visible"></div>

<!-- idle timing -->
<div data-zfb-island="Counter" data-props="{}" data-when="idle"></div>

<!-- media timing — hydrates when (max-width: 768px) first matches -->
<div data-zfb-island="MobileMenu" data-props="{}" data-when="media" data-media="(max-width: 768px)"></div>

On builds that include at least one island, one <script> tag is injected project-wide into <head>:

<script type="module" src="/assets/islands-<hash>.js"></script>

The shared islands bundle (islands-<hash>.js) contains all island components. It registers each one by the static marker name the scanner discovered (data-zfb-island attribute value), then calls mountIslands(), which walks every [data-zfb-island] element on the page, reads the serialised data-props, and calls hydrate() on the existing server-rendered DOM.

Consequences of the shared-bundle model

Because all islands are bundled together into a single file:

  • Every page with any island loads all islands' code. The single islands-<hash>.js bundle contains every island component in the project. A page that only uses ThemeToggle still downloads the code for Counter and SearchBox. This trades per-page load granularity for a simpler build pipeline and better cache efficiency across pages.

  • Pages with no islands get no script. If a project's pages contain no "use client" components, the build pipeline skips the <script> injection entirely. Zero bytes of JavaScript land on a fully static page.

  • Any island change rehashes the single bundle. Adding or modifying any island component produces a new content hash, which invalidates the browser's cached bundle for all pages. The tradeoff is that one cache entry covers all islands — once the bundle is cached, every page in the project benefits from the same cache hit.

Framework choices

zfb supports two frameworks for islands:

Config valueRuntime
"preact" (default)Preact + preact/jsx-runtime
"react"React 18 + react-dom/client

Set it once in zfb.config.ts:

export default {
  framework: "preact", // or "react"
};

This is a project-wide setting — one framework per project. The bundler (FrameworkKind enum in crates/zfb-islands) threads the choice through JSX transform options (--jsx-import-source) and the framework-specific hydration glue embedded in the shared bundle. You cannot mix Preact islands and React islands in the same project.

zfb does not support Vue, Svelte, or Solid. The FrameworkKind enum is intentionally a two-variant enum, not a plugin point. If you need a different framework, the escape hatches below cover the common cases.

For a deeper look at how the two adapters work, see Framework adapters.

Escape hatches for non-island client JS

Islands cover stateful UI components. For other client-side JavaScript needs, use the standard HTML mechanisms directly.

Inline scripts — write a <script> tag directly in your page TSX or layout:

export default function Layout({ children }) {
  return (
    <html>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'light';`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

This is the right tool for synchronous pre-hydration work that must run before the stylesheet parses (FOUC prevention, theme init, analytics setup).

External scripts — reference any .js file from public/ or a CDN:

<script src="/scripts/analytics.js" defer />
<script src="https://cdn.example.com/lib.js" defer />

Custom build steps — if you need to bundle additional TypeScript modules, add a separate esbuild or Rollup step to your build pipeline and reference the output from a <script src>. zfb does not auto-bundle arbitrary non-"use client" modules — only the islands pipeline runs automatically.

What you cannot do: import a regular (non-"use client") .ts or .tsx module from a page and expect its browser-side code to reach the client. Modules without the directive are server-only — SWC compiles and evaluates them at build time, and none of their bytes are included in the output.

When not to use islands

Islands ship JavaScript. That has a cost. Before adding one, ask whether you can solve the problem without it.

DOM class-swap toggles — accordions, disclosure menus, show/hide panels — are often solved with a few lines of plain CSS or a small inline <script>. The native <details> / <summary> element handles accordion behaviour with no JavaScript at all:

<details>
  <summary>Frequently asked question</summary>
  <p>The answer goes here.</p>
</details>

CSS-only approaches (:target, :checked + <label>, @starting-style) handle many interactive patterns that previously required JavaScript.

Islands are the right tool when:

  • The component has state that must survive beyond a single interaction (e.g., a cart, a user session, a multi-step form).

  • The component relies on browser APIs not available at build time (canvas, WebGL, getUserMedia, real-time data).

  • You would otherwise write the component twice — once for the server render, once for the client — and keep them in sync manually.

If the honest answer is "I just want a class toggled on click", reach for CSS or a tiny inline script first. Islands for "stateful UI you'd build twice" is the right heuristic.

For more on the pipeline shape, see Build pipeline and Build engine.

Revision History

Takeshi TakatsudoCreated: 2026-06-25T05:17:25+09:00Updated: 2026-06-25T05:17:25+09:00

AI Assistant

Ask a question about the documentation.