zfb
GitHub repository

Type to search...

to open search from anywhere

Islands

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.

components/theme-toggle.tsx in the bundled basic-blog template is the canonical real-world example — and the template's only island. 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 JSX API for non-default timing is the <Island> wrapper; for media-query hydration, pass both when="media" and a media query:

import { Island } from "@takazudo/zfb";
import MobileMenu from "./MobileMenu";

export default function Header() {
  return (
    <Island when="media" media="(max-width: 768px)">
      <MobileMenu />
    </Island>
  );
}

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 islands script. If a page contains no island markers, the build pipeline skips the shared islands <script> injection for that page. Other client-script mechanisms, if used, can still add JavaScript.

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

Browser bundle mode and configuration

Island code uses the same compile-time mode values as client scripts, module workers, and the page/SSR bundle:

Commandimport.meta.env.DEVimport.meta.env.PRODprocess.env.NODE_ENV
zfb devtruefalse"development"
zfb buildfalsetrue"production"

The mode is independent of minification. Additional bundle.loaders and bundle.define settings are also shared by islands and the workers they start. Define values are raw, public code substitutions, so they must never contain secrets.

TypeScript does not infer these bundler contracts. Add the matching ambient declarations so mode values, custom-loader imports, raw imports, and define names in island code pass zfb check and tsc.

Importing text with ?raw

An island or a first-party module in its import graph can load any project-local file as text with one exact form:

"use client";

import shaderSource from "./shaders/noise.frag?raw";

The target's extension does not matter and bundle.loaders does not change the result. The target is read as valid UTF-8, default-exported as a string, and treated as a terminal dependency — a .js?raw file is text, not code. Only a static default import with a literal relative specifier and the exact ?raw suffix is supported. Named, namespace, side-effect, type-only, dynamic, and re-export forms, other queries, extra parameters, non-literal paths, and targets outside the project root fail with a message that names the supported form.

During zfb dev, raw targets in island and client-script graphs are added to their live watch sets. Editing, deleting, or recreating the text file therefore invalidates the browser bundle even when its importer is unchanged. The same text transform works in the page/SSR pass, where raw edges also participate in route dependency tracking, but that does not add new filesystem watch roots. An SSR-only raw target outside the default watched roots (for example, under lib/) must be covered by an absolute extraWatchPaths, or its edits will not produce a dev filesystem event.

Module workers

Use the literal module-worker constructor that zfb can discover before esbuild runs:

"use client";

const searchWorker = new Worker(
  new URL("./workers/search.worker.ts", import.meta.url),
  { type: "module" },
);

The first URL argument must be a string literal naming an exact, project-local relative JS/TS file with no query or fragment, and the worker options must select type: "module". zfb leaves worker sources out of the server graph, bundles each worker as a self-contained browser entry, and rewrites the URL to:

./worker-<encoded-project-relative-path>.js?v=<graph-hash>

Island-owned workers are emitted flat under /assets/worker-*.js (with the configured base prefix, if any). The stable filename is a reversible encoding of the complete project-relative source path: separators become -s-, dots -d-, literal hyphens -h-, and other bytes -xHH-. For example, src/search/index.worker.ts becomes worker-src-s-search-s-index-d-worker-d-ts.js. The ?v= query is exactly eight lowercase hexadecimal characters and changes when the first-party graph or output-affecting bundle/resolver inputs change, including transitive imports, nested workers, raw files, and relevant TypeScript configuration. Dev watches that closure and removes stale companions after worker edges disappear.

The predictable prefix can anchor a per-worker response policy. For example, a Cloudflare-style public/_headers file can start with:

/assets/worker-*.js
  Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'

Tailor the directives to what your worker actually accesses. A path-style base prefixes the _headers route: for example, base: "/docs/" needs /docs/assets/worker-*.js. An absolute-URL base such as https://cdn.example.com/static/ instead needs the equivalent /static/assets/worker-*.js header rule on that asset origin; the site's _headers file cannot configure another origin. Workers owned by client scripts live in the separate /assets/client/worker-*.js directory and need a corresponding rule under the same base/origin policy.

The pre-pass does not traverse installed node_modules; third-party transitive worker constructors are left to their package tooling. A SharedWorker using the same literal new URL(..., import.meta.url) shape is a named hard error. Other non-literal or non-module constructor shapes are not part of this contract and should not be expected to be rewritten.

import.meta.glob support

zfb supports Vite's import.meta.glob(...) macro — importing every file matching a pattern in one call, handy for auto-registering a directory of sub-components or data files. Support is intentionally narrow: esbuild (which bundles islands) has no native knowledge of this Vite-only macro, so zfb expands it Rust-side, at build time, before esbuild ever sees the file. Only the one shape that expansion understands is accepted; anything else is a build error rather than a silent miscompile.

Supported form

"use client";

// Eager + string-literal pattern, anchored at this file's own directory.
const pages = import.meta.glob("./items/*.tsx", { eager: true });

This expands, at build time, into a plain object literal mapping each matched relative path to that module's namespace object — the same shape Vite produces:

{
  "./items/a.tsx": /* module namespace */,
  "./items/b.tsx": /* module namespace */,
}

Requirements for the supported form:

  • The second argument must be exactly { eager: true } — Vite's default (no second argument) is the lazy form, which is not supported here.

  • The pattern must be a string literal — a variable, a template literal, or an array of patterns is not supported.

  • The pattern must resolve under the importing file's own directory — a ../-escaping pattern is rejected outright.

  • No import, query, or as options — only the bare { eager: true } object is recognised; any other key fails the build.

Where it works

LocationSupported
A server-only page, layout, or component (never reachable from a "use client" island)Yes — always has been, via the general SSR shadow-copy bundler
A "use client" island's own fileYes, since issue #1404
A module transitively imported from a "use client" islandYes, since #1404, same restrictions as above, when the module is part of the island import graph and is written as a real expanded shadow-copy file
A file reachable only because it is matched by another island-reachable glob (a raw-mirrored glob target or subtree companion)No — see "Unsupported forms" below
A *.client.{ts,tsx,js,jsx} client script (or anything it imports)No — see "Client scripts" below

Before #1404, any import.meta.glob reachable from an island — even the supported eager + string-literal form — was a hard build error (a stopgap from issue #1387). The islands build now materialises a temporary shadow copy of the island-reachable source tree with each supported glob call already expanded, the same trick the SSR bundler has used for server-only modules since issues #665/#670.

There is one important boundary: the shadow also raw-mirrors files under each island-reachable glob module's directory so matched target files exist for esbuild, but those raw-mirrored target/subtree files are not recursively expanded. If one of those files contains its own import.meta.glob(...), zfb rejects it instead of shipping an unexpanded Vite-only macro to the browser. If the same file is also imported from the island graph in the normal way, it is a real expanded shadow copy and its supported glob keeps working.

Unsupported forms fail the build (or, in dev, warn and skip)

Any of the following fails zfb build with a message naming the offending file. During zfb dev the same message is logged as a warning and that rebundle tick is skipped, so the dev server stays up while you fix the file and save again:

  • the default lazy form — import.meta.glob("./items/*.tsx") with no options

  • { eager: false }

  • a non-literal pattern (a variable, a template literal, a computed expression)

  • the import, query, or as options

  • a pattern that escapes the file's own directory (../)

  • a glob module that lives outside the project root, or under node_modules, while still being reachable from an island (nothing to mirror into the shadow copy)

  • an import.meta.glob(...) call inside a JS-like file that is reachable only as a raw-mirrored glob target or subtree companion

The raw-mirrored check is intentionally conservative. An unused JS-like sibling (.js, .jsx, .ts, .tsx, .mjs, .cjs, .mts, or .cts) under a globbed subtree is still flagged if it contains a real glob call, because the shadow cannot prove whether esbuild will read that raw file later. Hoist the glob into an island-reachable module, move it out of the globbed subtree, or replace it with explicit static imports.

Client scripts: not supported, and not caught at build time

import.meta.glob is not supported in client scripts (*.client.{ts,tsx,js,jsx}, or any module they import). A graph that needs ?raw, module-worker, or plugin preprocessing may use a temporary mirror, but that stage deliberately does not expand globs. The call therefore reaches the browser semantically unexpanded. Unlike the island case above, this is not caught at build time: zfb build succeeds, and the browser throws when the script actually runs, because import.meta.glob is undefined outside of Vite. If you need a glob-like file list inside a client script, use one of the alternatives below.

Alternatives

  • A plugin virtual module — compute the file list yourself in Node (e.g. with node:fs or a glob library) inside a plugin's setup hook, and expose it via addVirtualModule. See Plugins for the full hook contract and a worked virtual-module example.

  • Explicit static imports — write the import statements out by hand, or generate a real .ts/.tsx file with a small pre-build script that zfb then bundles normally.

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

Client scripts or custom build steps — zfb automatically bundles *.client.* entries as a second browser-entry pipeline. For any other TypeScript module, add a separate esbuild or Rollup step and reference its output from a <script src>. Modules that are not reachable from a "use client" graph or a .client.* entry remain server-only.

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 JSX wrapper and hydration options, see <Island>. For more on the pipeline shape, see Build pipeline and Build engine.

Revision History

CreatedUpdated