Islands Architecture

Ship zero JavaScript by default. Hydrate only what needs interactivity.

The Concept

Most of any web page is static content: headers, text, images, footers. Only small pieces actually need JavaScript for interactivity -- a counter, a search bar, an interactive chart. Islands architecture treats each interactive piece as an independent "island" that hydrates on its own, while the rest of the page stays as pure static HTML.

The result: your pages load faster because the browser downloads and executes far less JavaScript. A blog post with a single comment widget only ships JS for that widget, not for the entire page.

Hydration Modes

What gives you fine-grained control over when each island hydrates. Choose the mode that matches the island's purpose:

Every trigger has two spellings. The samples below declare it as the mode prop on the Island marker, which writes it into data-island-mode; that attribute is what hydrateIslands() reads. In a compiled build you can write the same trigger as a client: directive on a JSX component, for example <NavMenu client:load />. The names line up one for one except for the first-interaction trigger, which the directive spells client:interaction and the marker spells mode: 'action'.

client:load

Hydrate immediately when the page loads. Use this for islands that must be interactive right away, such as a navigation menu or authentication widget.

Island({ name: 'NavMenu', mode: 'load' })

client:idle

Hydrate when the browser is idle, using requestIdleCallback. This is the default mode. Good for interactive elements that are not needed in the first moments of the page, such as a "like" button or share widget.

Island({ name: 'LikeButton', mode: 'idle' })

client:visible

Hydrate when the island scrolls into the viewport, using IntersectionObserver with a 200px root margin. Ideal for content below the fold: comment sections, related posts, charts that the user might never scroll to.

Island({ name: 'CommentSection', mode: 'visible' })

client:interaction

Hydrate on the first user interaction -- click, focus, hover, or touch. Nothing is fetched or executed until the user actually reaches for the widget. Perfect for accordions, tabs, or dropdown menus.

Island({ name: 'Accordion', mode: 'action' })

client:media

Hydrate when a CSS media query matches. Use this for mobile-only or desktop-only interactivity. For example, a hamburger menu that only needs JS on small screens:

import { Island, island } from 'what-framework/server';

// Server: the marker carries the trigger
Island({ name: 'MobileMenu', mode: 'media' })

// Client: the registration carries the query
island('MobileMenu', () => import('./MobileMenu.js'), {
  media: '(max-width: 768px)',
})

The Island Component

During server rendering, use the Island component to mark where an island should appear. It renders a wrapper <div> with data-island attributes that the client uses for hydration, and nothing else. The marker carries the island's name, not a reference to the component, so the server has nothing to render into it and it stays empty until hydrateIslands() loads and mounts the island:

import { h } from 'what-framework';
import { Island } from 'what-framework/server';

function ProductPage({ product }) {
  return h('div', null,
    // Static HTML, no JS shipped
    h('h1', null, product.name),
    h('p', null, product.description),
    h('img', { src: product.image, alt: product.name }),

    // Interactive island, JS loaded on interaction
    Island({
      name: 'AddToCart',
      mode: 'action',
      props: { productId: product.id, price: product.price },
    }),

    // Island that hydrates when scrolled into view
    Island({
      name: 'ReviewSection',
      mode: 'visible',
      props: { productId: product.id },
    }),
  );
}

Props passed to the Island component are serialized as JSON in a data-island-props attribute and automatically deserialized when the island hydrates on the client. Children are the only thing that ends up inside the marker on the server, so the two markers above are blank regions for crawlers and for the paint before their triggers fire. You can pass children to fill that gap, but keep in mind that hydrateIslands() hydrates the island over any children it finds rather than replacing them, so they have to match what the island component renders.

Two different components are called Island

The Island imported from what-framework/server is the registry marker documented here: it knows only the island's name, so the server cannot render the component and the marker is empty unless you supply children. The Island exported from what-framework is a different component that takes the component itself: h(Island, { component: AddToCart, mode: 'action', productId: 1 }). Rendered on the server that one puts the component's real HTML inside the marker, on the client it hydrates in place over that HTML, and it sets data-island-self so hydrateIslands() leaves it alone. what-compiler emits the same component for a client: directive such as <AddToCart client:interaction />. Reach for it when the island has content worth putting in the HTML; the registry form suits widgets with no meaningful static representation, or components your server build does not have.

Registering Islands

On the client side, register each island with a name and a dynamic import loader. This tells What which component to load when it is time to hydrate:

import { island, hydrateIslands } from 'what-framework/server';

// Register islands with lazy loaders
island('AddToCart', () => import('./components/AddToCart.js'));
island('ReviewSection', () => import('./components/ReviewSection.js'));
island('NavMenu', () => import('./components/NavMenu.js'));

// Find all [data-island] elements and schedule hydration
hydrateIslands();

You can also use autoIslands to register and hydrate in one step:

import { autoIslands } from 'what-framework/server';

autoIslands({
  AddToCart: { loader: () => import('./components/AddToCart.js'), mode: 'action' },
  ReviewSection: { loader: () => import('./components/ReviewSection.js'), mode: 'visible' },
  NavMenu: { loader: () => import('./components/NavMenu.js'), mode: 'load' },
});

Island Stores

When multiple islands need to share state (for example, a cart icon in the header and an "Add to Cart" button in the body), use createIslandStore. These stores are serialized during SSR and hydrated on the client so state survives the server-to-client transition:

import { h } from 'what-framework';
import { createIslandStore, useIslandStore } from 'what-framework/server';

// Declare a shared store. Same name = same instance.
const cartStore = createIslandStore('cart', {
  items: [],
  count: 0,
});

// Islands read and write the store like a normal object
function AddToCart({ product }) {
  return h('button', {
    onclick: () => {
      cartStore.items = [...cartStore.items, product];
      cartStore.count = cartStore.count + 1;
    },
  }, 'Add to cart');
}

// A second island reads the same store by name
function CartBadge() {
  const cart = useIslandStore('cart', { items: [], count: 0 });
  return h('span', {}, () => `Cart (${cart.count})`);
}

Module-scope stores are request-scoped on the server

A createIslandStore() call at module scope returns a concrete store in the browser, but on the server it returns a lazy handle. Reading or writing it outside an active render throws [what-server] Island store "cart" was accessed outside an active server render. That is deliberate: it is what stops one request from observing another request's cart. Touch the store only from inside a component that renderDocument or renderPage is rendering, never at module scope. Only keys present in the initial state become reactive properties, so a key added later is inert.

You do not have to serialize the stores yourself. renderDocument() takes a snapshot at the end of the render and writes it into the consolidated <script id="__what_data"> payload, and hydrateIslands() reads islandStores back out of that same script before it hydrates anything:

// Server: renderDocument emits the payload for you
// <script id="__what_data" type="application/json">
//   {"loaderData":null,"resources":{},"islandStores":{"cart":{"items":[],"count":3}}}
// </script>

// Client: hydrateIslands() restores the stores first, then the islands
hydrateIslands();

If you assemble the document by hand instead of using renderDocument, call serializeIslandStores() during the render and emit it under that same id="__what_data" script as an islandStores key, or call hydrateIslandStores(data) yourself on the client. serializeIslandStores() returns {} when called outside a render, because there is no request whose stores it could snapshot.

Progressive Enhancement

Not every interactive feature needs a full island. For simple enhancements -- like a form that submits via fetch instead of a page reload -- use the progressive enhancement helpers:

import { enhance, enhanceForms } from 'what-framework/server';

// Enhance all forms with data-enhance attribute
enhanceForms();

// Or target specific elements with a custom handler
enhance('.copy-button', (el) => {
  el.addEventListener('click', () => {
    navigator.clipboard.writeText(el.dataset.text);
    el.textContent = 'Copied!';
  });
});

Enhanced forms dispatch form:response and form:error custom events so you can react to the result without mounting a full component. A same-origin form needs a CSRF token before enhanceForms will submit it at all: put <meta name="what-csrf-token"> in the head (that is what csrfMetaTag() emits, and the deploy adapters emit it on uncached renders), or set data-no-csrf="true" on a form that posts somewhere with its own policy. Without one, the enhancer issues no request, warns on the console and dispatches form:error with "Missing CSRF token":

// In your HTML
// <meta name="what-csrf-token" content="abc-123...">
// <form data-enhance method="POST" action="/subscribe">
//   <input name="email" type="email" />
//   <button>Subscribe</button>
// </form>

// Listen for the response
document.querySelector('form').addEventListener('form:response', (e) => {
  // A followed same-origin redirect would navigate away and
  // discard this innerHTML. Cancel the event to stay put.
  e.preventDefault();
  if (e.detail.ok) {
    e.target.innerHTML = '<p>Subscribed!</p>';
  }
});

The form:response detail is { response, ok, redirected } and the event is cancelable. If the fetch followed a same-origin redirect and you do not cancel, the enhancer navigates the page there so the enhanced submit lands where a plain HTML submit would. The encoding follows the form's own enctype, exactly as a browser submit does, so add enctype="multipart/form-data" when you need to upload file bytes rather than just file names. The token is looked up from the meta tag, then a hidden what-csrf-token or _csrf field, then the what-csrf cookie, and is never attached to a cross-origin request.

Priority Hydration

When multiple islands are scheduled to hydrate at the same time, What processes them in priority order. Higher priority islands hydrate first:

// Critical island, hydrates first
Island({ name: 'SearchBar', mode: 'load', priority: 10 })

// Low priority, hydrates after everything else
Island({ name: 'Analytics', mode: 'idle', priority: 0 })

Priority only orders islands against each other inside the hydration queue, and an island joins the queue when its trigger fires, not when it is registered. The mode adds its own weight on the way in: load adds 1000 to the declared priority and action adds 500, so a priority number really orders islands within the same mode.

boostIslandPriority(name, newPriority) re-sorts entries that are already queued. An island still waiting on requestIdleCallback, an IntersectionObserver, a media query or a first interaction is not queued yet, so boosting it does nothing.

Debugging

Call getIslandStatus() at any time to inspect the current state of the island system:

import { getIslandStatus } from 'what-framework/server';

const status = getIslandStatus();
console.log(status);
// {
//   registered: ['AddToCart', 'ReviewSection', 'NavMenu'],
//   hydrated: 1,
//   pending: 2,
//   queue: [
//     { name: 'AddToCart', priority: 500 },   // mode 'action' adds 500
//     { name: 'ReviewSection', priority: 0 },
//   ],
//   stores: ['cart'],
// }

Each island also dispatches an island:hydrated custom event when it finishes hydrating, which you can use for performance tracking or analytics.

Islands vs Regular Components

Islands are designed for multi-page applications with server-side rendering. Each page is its own HTML document, and islands add interactivity to specific parts. If you are building a single-page application where the whole page is already client-rendered, use regular components instead -- there is no benefit to the islands pattern when all your JavaScript is already loaded.