Islands
Most of a PRISM page is server-rendered HTML: the runtime evaluates your route bundle, produces a string of HTML, and sends it. An island is the exception - a component in that otherwise-static page that becomes interactive in the browser. The markup around it stays server-rendered; the island is the small, self-contained piece that gets wired up to live state and event handlers on the client.
This is how bext-lite ships client interactivity without shipping a client-side framework for the whole page. You send static HTML plus a tiny per-island bundle, and only the interactive parts hydrate.
Why islands are free on every target#
The island hydration runtime is pure browser JavaScript with zero server coupling. It imports nothing from the host, calls no bridge function, and does not care which engine rendered the page. It is the same file in a browser tab, in a desktop WebView, in an edge response, and in an iOS or Android WebView.
That is the whole reason client interactivity costs nothing extra as you move across targets. The four layers that change per target - compile, render, host, shell - are all server-side (see Architecture). The client runtime sits beside them and never changes. So when you pick up mobile, edge, or the browser target, your islands come along unchanged, at no extra cost.
How hydration works#
Hydration here is resumable: there is no virtual DOM and no re-render of the markup the server already produced. The server emits the static HTML with small resumability markers, and the client attaches reactivity onto those markers in place.
The server render leaves behind:
<!--bsN-->...<!--/bsN-->around a text binding (a value the client keeps live)data-bs-attrNon an element with a reactive attributedata-bs-on<event>="<id>"on an element with an event handler<!--bsListN-->...<!--/bsListN-->around a reactive list
On the client, the loader mounts each island root by:
- Locating the island root (a
<bext-island data-runtime="signals">element). - Running the component once to rebuild its reactive graph - the same call sequence the server made, so the handler and binding IDs line up.
- Walking the existing server-rendered DOM. For each marker it attaches an effect (text and attribute bindings), adds an event listener, or installs a list binding.
- Stripping the marker comments and
data-bs-*attributes after binding, so the DOM ends up clean.
Why the component runs twice - once on the server, once on the client: closures are not serializable. The component body builds the reactive graph declaratively, so running it once on each side is the cheapest way to rebuild it. The static markup is rendered only on the server; the client re-run exists purely to reconstruct the handlers and bindings that attach to it.
Signals update only the exact text node bound to a value, not the whole component. Unlike a full client re-render, sibling elements stay untouched, along with their focus, scroll position, and running animations.
The island loader#
Each "use signals" file gets a generated client entry that imports
hydrateSignalsIsland and mounts on DOMContentLoaded - the build pipeline wires
this up automatically, you do not write it. The loader itself is about 930 bytes.
The AOT exporter emits the compiled islands alongside the rest of your app:
dist-lite/
islands/<name>.js compiled signals islands
public/islands/*.js the loader + per-island client entries
Island scripts are cache-busted with a ?v= query param on the script src, so a
new build invalidates the browser cache without a filename change.
A small island#
Author the interactive component as a "use signals" file:
"use signals";
/** @jsxImportSource @bext-stack/framework/signals */
import { signal, computed } from "@bext-stack/framework/signals";
export default function Counter(props: { initial?: number }) {
const count = signal(props.initial ?? 0);
const doubled = computed(() => count.value * 2);
return (
<div>
<p>count: {count.value} (x2 = {doubled.value})</p>
<button onClick={() => { count.value++; }}>+1</button>
<button onClick={() => { count.value--; }}>-1</button>
</div>
);
}
Embed it in an otherwise server-rendered page with signalsIsland:
import { signalsIsland } from "@bext-stack/framework/signals";
import Counter from "../islands/Counter";
export default function Page() {
return signalsIsland("Counter", Counter, { initial: 7 });
}
The server renders the counter to HTML with resumability markers. On the client
the signals runtime walks those markers, binds each {count.value} to its DOM
text node, and updates reactively on click. There is no virtual DOM and no
re-render of the surrounding markup: only the two bound text nodes change, and the
buttons stay exactly as the server rendered them.
Islands versus the React AOT path#
bext-lite gives you two ways to add interactivity, and they are different tools:
- Signals islands are the lightweight default. A small reactive component hydrates in place through the pure-browser signals runtime described above. No framework runtime ships for the rest of the page, and hydration is resumable (no virtual DOM).
- The React AOT path is for
"use client"React pages. The exporter bun-builds a real React SSR bundle and renders it withReactDOMServer.renderToString- on V8 at build or serve time, and on QuickJS on-device. You get the full React programming model at a heavier weight. See React for that path and The engine decision for how React renders on both engines.
Both start as server-rendered HTML. The difference is what happens next: an island resumes through the signals runtime, while a React page carries React. When you only need a live counter, a toggle, or a filtered list, an island is the byte-for-byte cheaper choice and it is free on every target.
Related#
- Architecture - the four server-side layers the client sits beside
- React - the
"use client"React AOT render path - Examples - the device-playground app, which layers liveness onto server-rendered pages
- Deploy targets - why the client runtime is free everywhere