bext.dev
DocsPRISM (bext-native)
PRISM (bext-native)8 min read
On this page

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#

  1. The server renders the full page as HTML, including static fallback content for each island
  2. Island markers (<bext-island>) embed the component name and serialized props
  3. A minimal client-side script finds all island markers and lazy-loads their JavaScript modules
  4. Each island hydrates independently — no global framework runtime needed
code
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:

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

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

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

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

typescript
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#

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

typescript
island("Counter", { initial: 0 }, `
  <div class="counter">
    <button class="dec">-</button>
    <span class="count">0</span>
    <button class="inc">+</button>
  </div>
`)

Preact island#

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

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

tsx
// 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>
  );
}
Tip

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:

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

typescript
// 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>
`)
javascript
// 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:

typescript
// 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>
    `,
  };
}
typescript
// 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#

code
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
  • 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()
Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.