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

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:



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



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



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



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



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>
           i}
            render={(row) => (
              <tr>{props.columns.map((c) => <td>{row[c]}</td>)}</tr>
            )}
          />
        </tbody>
      </table>
    </div>
  );
}

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




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



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

Performance profile

Metric Value
Server render time ~50 us
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