Skip to content

Islands Architecture

Islands architecture is a pattern where you ship mostly static HTML from the server and activate only the interactive regions (islands) with JavaScript on the client. The rest of the page remains static HTML and ships little to no client-side JavaScript unless a region needs it.

Key distinction: Islands architecture is a rendering pattern, not a deployment or team architecture. It controls how a page renders and hydrates. It does not control how you organize teams or codebases.

Traditional SPAs hydrate the entire page tree, even regions that will never change. This means:

  • Larger JavaScript bundles
  • Slower time-to-interactive (TTI) on slower networks/devices
  • Wasted CPU hydrating static content
  • Poorer fallback behavior if JavaScript fails to load

Islands architecture inverts this: ship HTML-first, then opt specific regions into JavaScript when they need it. You send less JavaScript to the client and limit hydration to interactive regions, which improves load and interaction performance.


Quick mental model:

Islands control when and where JavaScript runs. They optimize rendering and hydration within a single application. They do not control team boundaries or independent deployment.

On hydration: mountly hydrates the interactive islands. The rest of the page stays static HTML. The browser runs JavaScript setup (event listeners, state initialization, and so on) for regions that need interactivity and leaves static regions untouched.


mountly is a component mounting library rather than a full SSR framework. Your server or framework renders the initial HTML and marks the interactive regions with data-* attributes; mountly reads them and decides when to load and mount the client module.

<!-- Server renders the island, and says it is already live -->
<div data-mountly="/widgets/cart.js" data-mountly-state="mounted" data-props='{"items":3}'>
<div class="cart-summary">3 items, $42</div>
</div>
<script type="module" src="/mountly/auto.js"></script>

auto.js (2.3 KB gzipped) wires every island on the page. Islands added later — by another widget, an htmx swap, a Turbo frame — are picked up automatically, so nested islands need no ordering configuration.

Two ways in:

  • The script tagmountly/auto, nothing for you to write
  • As a dependencyimport { mountly } from "mountly" when you need an alias map, a custom loader, or a scoped root

Either way mountly stays composable with Astro, Next.js, Remix, Rails, Django, CMS templates, or plain server-rendered HTML. Those tools own routing and rendering; mountly owns the portable activation layer.

Control when JavaScript loads and runs.

Trigger When JS loads Best for
click On first click Forms, modals, drawers
hover On hover Menus, previews, tooltip-like widgets
focus On focus Inputs, search boxes, accessible controls
idle When browser is idle via requestIdleCallback Below-the-fold content, low-priority features
viewport When element enters viewport Lazy sections, cards, comments, feeds
media When a media query matches Device/layout-specific UI
url-change When URL/history changes Search/filter panels, route-aware widgets
never Only if manually triggered Server-rendered features that may never need JS

Islands hydrate on page load, on user interaction (click, focus, hover), when they enter the viewport, when the browser is idle, or when a media query matches. You choose when JavaScript runs based on user behavior and network conditions.

The attributes are the contract between the server-rendered HTML and mountly’s client runtime.

Attribute Default What it controls
data-mountly Required Module URL, or a key in the host script’s data-mountly-urls map
data-on click Activation trigger(s). never leaves the island inert until code mounts it
data-preload None Fetch the module without mounting — usually hover or viewport
data-props {} JSON props. A <script type="application/json"> child works too
data-target The island element Where to mount, when the trigger is not the container
data-toggle Off A second activation unmounts instead of doing nothing
data-css Sibling .css none, or an explicit stylesheet URL
data-module-url data-mountly Bundle URL reported to the widget as moduleUrl, when the island loads by key
data-mountly-state Set by mountly idle / loading / mounted / error — style each with CSS

A trigger reads as kind or kind:arg, so a trigger never needs a second attribute to hold its argument:

<div data-mountly="/w.js" data-on="media:(min-width: 60rem)"></div>
<div data-mountly="/w.js" data-on="viewport:200px"></div>
<div data-mountly="/w.js" data-preload="hover:300"></div>
<!-- whichever comes first: mouse users click, keyboard users focus -->
<button data-mountly="/w.js" data-on="click focus"></button>

Anything the attributes can’t express goes through mountly():

import { mountly, mount, unmount, update } from "mountly";
const stop = mountly({
root: document.querySelector("#app"),
urls: { cart: "/widgets/cart.js" },
observe: true,
// one escape hatch instead of retry / retryDelayMs / auth / test-double knobs
load: (url) => import(url),
});

There is one rule: data-mountly-state="mounted" in the server’s HTML means “this island is already live, leave it alone.”

<div data-mountly="/widgets/product-card.js" data-mountly-state="mounted">
<a class="product-card" href="/products/42">View product</a>
</div>

Use when: the server-rendered HTML is complete and functional — product cards, links, summary panels, read-only content.

Omit the attribute and the island activates on its trigger, replacing whatever the server rendered. That is the placeholder case: the client needs state, event listeners, browser APIs, or a different DOM shape from the server fallback.

<div data-mountly="/widgets/counter.js">
<button>Open counter</button>
</div>

If you need to mount over server content anyway, call mount(el) — an explicit act, rather than a flag the markup carries.

mountly islands can degrade without JavaScript. The server-rendered HTML remains visible even if the JS bundle fails to load or execute.

This requires one pattern:

<head>
<!-- Global stylesheet for no-JS fallback -->
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div data-mountly="/widgets/card.js">
<span class="styled-widget">Server-rendered content</span>
</div>
<!-- Host script that mounts islands when JS is available -->
<script type="module" src="/mountly/auto.js"></script>
</body>

When JS is on, mountly can load the component and pass CSS hints through to adapters. By default the widget mounts in light DOM and the document <link> continues to apply; if shadow: true is set, the same CSS is adopted into the shadow root. When JS is off, the global stylesheet keeps the SSR content readable.

This resembles a <noscript> fallback, except the fallback is the server-rendered island content itself.

  • No activation API: attributes on the server-rendered region, and a 2.3 KB script tag
  • Framework-agnostic widgets: build islands in React, Vue, Svelte, Solid-compatible modules, or plain widget modules
  • Host-framework agnostic: works with Astro, Next.js, Remix, server templates, CMS pages, and plain HTML
  • Gradual adoption: mix islands and full-page apps on the same site
  • No-JS fallback path: keep meaningful HTML in the page before mountly activates anything
  • Light DOM is the default, shadow DOM is opt-in. Light DOM integrates with global styles and form APIs; shadow: true gives you hard style isolation (good for independent islands embedded in unknown hosts). Pick based on your needs.
  • Islands stay independent. To share React Context, query clients, or routers across islands, you coordinate them by hand (event bus, URL state, external store).
  • Custom elements have form constraints (custom element approach only). When you register custom elements with defineMountlyFeature(), form integration differs. Use plain data-mountly islands if you need native form behavior.
  • Islands architecture does not address team ownership or independent deployment. If you need team-driven boundaries, separate CI/CD pipelines, or cross-team ownership, microfrontends fit better.
  • Your backend already renders HTML and you need selective client activation
  • You want portable islands without moving the whole site to a new framework
  • You are shipping components to unknown hosts, CMS pages, or third-party pages
  • You want Astro-like partial hydration behavior in places that are not Astro apps
  • You want something that can also compose with Astro rather than replace it

Islands architecture and microfrontends solve different problems.

Islands architecture optimizes rendering and hydration within a single application. It controls how regions become interactive and when JavaScript loads, and assumes unified ownership and deployment.

Microfrontends draw independently developed and deployed application boundaries. They solve team autonomy, separate CI/CD pipelines, and cross-team ownership. Islands do not.

If you need team-driven boundaries and independent deployments, consider microfrontends. If you want to optimize JavaScript loading and hydration within one app, islands are the fit.

When to reach for a full islands framework instead

Section titled “When to reach for a full islands framework instead”
Scenario Consider Why
Content-first site (blogs, marketing, docs) Astro Built-in SSR, content collections, auto island generation, and integrated routing make this much simpler
Deno ecosystem Fresh Native islands runtime support; no separate activation layer needed
Every region shares app state Full-page app (React, Next.js, Remix) If Context, stores, and routers cross island boundaries, one app tree is simpler than coordinating independent islands
Custom element form integration Full-page app If you need native form submission or deep form API integration in one unified tree, a full-page app model is usually simpler than custom-element boundaries.
Team autonomy & independent deployment Microfrontends Islands architecture does not solve team boundaries, deployment independence, or cross-team ownership concerns

Representative outcomes for a React island:

Build Size impact Includes
Self-contained Largest React + ReactDOM + adapter + component
Peer build Smaller Component + adapter, with framework runtime provided by host
SSR-only fallback No client JS for that island Server-rendered HTML + CSS

The exact numbers depend on your bundler, framework version, component code, and whether dependencies are externalized. Islands make sense when some regions can avoid JavaScript or load it later. If every region needs immediate shared client state, full-page hydration may be simpler.

Feature mountly Islands Astro Fresh Full-page SPA
Server-side rendering You provide it Built in Built in Usually separate
Selective hydration Yes Yes Yes No
Works without JS Yes, if SSR fallback is meaningful Yes Yes Usually no
UI framework choice Adapter-based Multi-framework integrations Preact-first App choice
Host/build-tool agnostic Yes No No Depends
Automatic island generation No Yes Yes N/A
Best fit Portable activation layer Content sites Deno islands apps Deep client apps