Islands Architecture
bext supports the islands architecture pattern: your pages are server-rendered as static HTML with zero JavaScript by default. Only specific interactive components ("islands") are hydrated on the client. This gives you the best of both worlds — fast initial loads with no JS payload, plus rich interactivity where you need it.
How it works
- The server renders the full page as HTML, including static fallback content for each island
- Island markers (
<bext-island>) embed the component name and serialized props - A minimal client-side script finds all island markers and lazy-loads their JavaScript modules
- Each island hydrates independently — no global framework runtime needed
Server HTML (zero JS by default):
┌──────────────────────────────────────┐
│ <nav>Home | Blog | About</nav> │ ← Static HTML (no JS)
│ │
│ <h1>Product Detail</h1> │ ← Static HTML (no JS)
│ <p>Description text...</p> │
│ │
│ <bext-island data-component="Cart"> │ ← Island marker
│ <button>Add to Cart</button> │ ← Fallback HTML (works without JS)
│ </bext-island> │
│ │
│ <footer>© 2026</footer> │ ← Static HTML (no JS)
└──────────────────────────────────────┘
Client hydration (only for islands):
Cart.js loaded → mounts over fallback → interactive button
Two flavors: React vs signals
bext ships two hydration models for islands. Both produce a
<bext-island> host element on the server; the loader script picks
which client runtime to fetch based on the data-runtime attribute.
"use client" (React) |
"use signals" (bext) |
|
|---|---|---|
| Bundle size | ~45 KB minimum (react + react-dom) | ~3 KB runtime + component |
| Hydration | re-render with hydrateRoot, diff against server DOM |
walk markers, attach subscriptions to existing DOM |
| Mental model | components return VDOM, framework reconciles | functions create signals; reads track them |
| When to pick | feature-rich SPAs, react libs, devtool integration | small interactive widgets, perf-sensitive renders |
The React-based path is documented below — it's the more familiar "island" pattern. For the signals path (smaller bundle, no re-render, DOM-stability across updates), see Signals. Both paths can coexist on the same page.
Server API
island(name, props, fallback)
Render an island marker with server-side fallback HTML:
import { island } from "@bext-stack/framework";
function ProductPage(product: Product): string {
return `
<h1>${product.name}</h1>
<p>${product.description}</p>
<div class="price">$${product.price}</div>
${island("AddToCart", { productId: product.id, price: product.price },
`<button class="btn">Add to Cart — $${product.price}</button>`
)}
`;
}
Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string |
Component name. Maps to /islands/{name}.js on the client. |
props |
Record<string, any> |
Serializable props passed to the client component. |
fallback |
string |
Static HTML shown before hydration (and if JS is disabled). |
Output HTML:
<bext-island data-component="AddToCart" data-props='{"productId":42,"price":29.99}'>
<button class="btn">Add to Cart — $29.99</button>
</bext-island>
islandScript(basePath?)
Generate the client-side hydration script. Include it once in your template, typically before </body>:
import { islandScript } from "@bext-stack/framework";
function renderPage(page: Page, ctx: RenderContext): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<title>${page.title}</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main>${page.html}</main>
${islandScript()}
</body>
</html>`;
}
Default output:
<script type="module">
document.querySelectorAll('bext-island').forEach(async function(el){
var n=el.dataset.component,p=JSON.parse(el.dataset.props||'{}');
try{var m=await import('/islands/'+n+'.js');m.mount(el,p);}catch(e){console.warn('Island '+n+':',e);}
});
</script>
Custom base path:
islandScript("/assets/components")
// Loads from /assets/components/{name}.js instead of /islands/{name}.js
Writing island components
Each island is a standalone JavaScript module in your public/islands/ directory. It must export a mount(el, props) function:
Vanilla JS island
// public/islands/Counter.js
export function mount(el, props) {
let count = props.initial ?? 0;
const display = el.querySelector('.count');
const incBtn = el.querySelector('.inc');
const decBtn = el.querySelector('.dec');
function update() {
display.textContent = count;
}
incBtn.addEventListener('click', () => { count++; update(); });
decBtn.addEventListener('click', () => { count--; update(); });
}
Server-side:
island("Counter", { initial: 0 }, `
<div class="counter">
<button class="dec">-</button>
<span class="count">0</span>
<button class="inc">+</button>
</div>
`)
Preact island
// public/islands/SearchBox.js
import { h, render } from 'https://esm.sh/preact';
import { useState } from 'https://esm.sh/preact/hooks';
function SearchBox({ placeholder, endpoint }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
async function handleInput(e) {
const q = e.target.value;
setQuery(q);
if (q.length < 2) { setResults([]); return; }
const res = await fetch(`${endpoint}?q=${encodeURIComponent(q)}`);
setResults(await res.json());
}
return h('div', { class: 'search' },
h('input', { type: 'text', placeholder, value: query, onInput: handleInput }),
h('ul', null, results.map(r =>
h('li', { key: r.id }, h('a', { href: r.url }, r.title))
))
);
}
export function mount(el, props) {
render(h(SearchBox, props), el);
}
React island
// public/islands/DataGrid.js
import React from 'https://esm.sh/react';
import ReactDOM from 'https://esm.sh/react-dom/client';
function DataGrid({ columns, endpoint }) {
const [rows, setRows] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch(endpoint)
.then(r => r.json())
.then(data => { setRows(data); setLoading(false); });
}, [endpoint]);
if (loading) return React.createElement('div', null, 'Loading...');
return React.createElement('table', { className: 'data-grid' },
React.createElement('thead', null,
React.createElement('tr', null,
columns.map(col => React.createElement('th', { key: col }, col))
)
),
React.createElement('tbody', null,
rows.map((row, i) =>
React.createElement('tr', { key: i },
columns.map(col => React.createElement('td', { key: col }, row[col]))
)
)
)
);
}
export function mount(el, props) {
const root = ReactDOM.createRoot(el);
root.render(React.createElement(DataGrid, props));
}
bext signals island
Same component as the React DataGrid above, written as a signals
island. No bundle of React; no re-render on update. Hydration runs
once and binds via marker comments.
// src/components/DataGrid.tsx
"use signals";
/** @jsxImportSource @bext-stack/framework/signals */
import { signal, computed, effect, List } from "@bext-stack/framework/signals";
export default function DataGrid(props: { columns: string[]; endpoint: string }) {
const rows = signal<any[]>([]);
const loading = signal(true);
// Effects run on hydrate (once) — fetch the data and update the
// signals. The reactive markers in the JSX below pick up the new
// values automatically; no re-render of the table shell.
effect(() => {
fetch(props.endpoint)
.then((r) => r.json())
.then((data) => {
rows.value = data;
loading.value = false;
});
});
return (
<div>
{loading.value ? <p>Loading…</p> : null}
<table>
<thead>
<tr>{props.columns.map((c) => <th>{c}</th>)}</tr>
</thead>
<tbody>
<List
each={rows}
key={(_, i) => i}
render={(row) => (
<tr>{props.columns.map((c) => <td>{row[c]}</td>)}</tr>
)}
/>
</tbody>
</table>
</div>
);
}
For small interactive widgets (counters, toggles, simple lists) prefer the signals path — it ships ~3 KB instead of React's ~45 KB and updates the existing DOM without a re-render. See Signals for the full API.
Mount via signalsIsland("DataGrid", DataGrid, { columns, endpoint })
on a PRISM page. The build pipeline auto-discovers the "use signals"
directive and emits a per-island bundle that imports
hydrateSignalsIsland. See the signals page
for primitives, the auto-wrap compile pass, and <List> reconciliation
details.
Zero JS for non-interactive pages
If a page has no island() calls, the islandScript() still renders but does nothing — querySelectorAll('bext-island') returns an empty NodeList and the loop body never executes. The script itself is ~200 bytes minified.
For pages that are guaranteed to have no islands, you can conditionally include the script:
function renderPage(page: Page, ctx: RenderContext): string {
const hasIslands = page.html.includes("bext-island");
return `<!DOCTYPE html>
<html lang="en">
<head><title>${page.title}</title></head>
<body>
<main>${page.html}</main>
${hasIslands ? islandScript() : ""}
</body>
</html>`;
}
Progressive enhancement
Islands are designed for progressive enhancement. The server-rendered fallback HTML is fully functional without JavaScript:
// Server: render a working form as fallback
island("ContactForm", { endpoint: "/api/contact" }, `
<form action="/api/contact" method="POST">
<label>Name <input type="text" name="name" required></label>
<label>Email <input type="email" name="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>
`)
// Client: enhance with AJAX submission and validation
// public/islands/ContactForm.js
export function mount(el, props) {
const form = el.querySelector('form');
const button = el.querySelector('button');
form.addEventListener('submit', async (e) => {
e.preventDefault();
button.disabled = true;
button.textContent = 'Sending...';
const data = Object.fromEntries(new FormData(form));
const res = await fetch(props.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
el.innerHTML = '<p class="success">Message sent! We\'ll be in touch.</p>';
} else {
button.disabled = false;
button.textContent = 'Send';
alert('Failed to send. Please try again.');
}
});
}
Without JavaScript: The form submits normally via POST, the server processes it, and returns a response page.
With JavaScript: The form submits via fetch, shows a loading state, and displays inline success/error feedback.
Complete example
A product listing page with a search island and add-to-cart islands:
// server/pages/products.ts
import { island, islandScript } from "@bext-stack/framework";
import type { Page } from "@bext-stack/framework";
import { dbQuery } from "@bext-stack/framework/bridge";
export function getProductsPage(): Page {
const { rows: products } = dbQuery("./shop.db", "SELECT * FROM products WHERE active = 1");
const productCards = products!.map((p: any) => `
<div class="product-card">
<img src="/images/${p.slug}.jpg" alt="${p.name}" loading="lazy">
<h3>${p.name}</h3>
<p class="price">$${p.price}</p>
${island("AddToCart", { id: p.id, name: p.name, price: p.price },
`<button class="btn-add">Add to Cart</button>`
)}
</div>
`).join("");
return {
title: "Products",
description: "Browse our product catalog",
html: `
<h1>Products</h1>
${island("ProductSearch", { endpoint: "/api/products/search" },
`<input type="search" placeholder="Search products..." class="search-input">`
)}
<div class="product-grid">
${productCards}
</div>
`,
};
}
// server/template.ts
import { islandScript, stylesheet } from "@bext-stack/framework";
import type { Page, RenderContext } from "@bext-stack/framework";
export function renderPage(page: Page, ctx: RenderContext): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${page.title} — My Shop</title>
${stylesheet("/styles.css")}
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
${ctx.authHtml}
</nav>
<main>${page.html}</main>
${islandScript()}
</body>
</html>`;
}
Project structure
my-shop/
├── server/
│ ├── entry.ts # createSite()
│ ├── pages/
│ │ └── products.ts # Server-rendered product page
│ └── template.ts # HTML shell with islandScript()
├── public/
│ ├── islands/
│ │ ├── AddToCart.js # Client-side cart interactions
│ │ └── ProductSearch.js # Client-side search with autocomplete
│ ├── styles.css
│ └── images/
└── bext.config.toml
Illustrative payload profile
Payload size depends on the chosen island runtime and component. The values below describe this example, not the canonical SSR shootout; for current rendering-only numbers see PRISM performance.
| Metric | Value |
|---|---|
| Server render time | Page-dependent; 15.7 µs compiled / 75.3 µs automatic on the canonical 38.3 KB fixture |
| Initial HTML payload | ~8 KB (no JS framework) |
| Island JS loaded | ~3 KB per island (Preact) or ~0.5 KB (vanilla) |
| Time to interactive | < 100 ms (islands hydrate independently) |
| Pages without islands | 0 KB JavaScript |
Related
- PRISM Framework — the server framework that renders island placeholders
- Signals — lightweight alternative hydration model (~3 KB, no React)
- Client Runtime — SPA-like navigation and live reload for the same page
- Server Actions —
"use server"mutations that complement islands - React, Preact & Solid — using framework libraries inside island
mount()