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 explicitly needed.

Key distinction: Islands architecture is a rendering pattern, not a deployment or team architecture. It controls how a page renders and hydrates—not how teams or codebases are organized.

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 only when they need it. By reducing JavaScript sent to the client and limiting hydration work to interactive regions only, islands improve load and interaction performance.


Quick mental model:

Think of islands as controlling when and where JavaScript runs, not how teams or codebases are organized. Islands optimize rendering and hydration within a single application, not team boundaries or independent deployment.

On hydration: Only interactive islands are hydrated—the rest of the page remains static HTML. This means the browser only runs JavaScript setup (event listeners, state initialization, etc.) for regions that need interactivity, leaving static regions untouched.


mountly is not a full SSR framework. It is a component mounting library that activates server-rendered regions using the mountIslandFeature() API. Your server or framework renders the initial HTML and writes a data-mountly-island payload. mountly reads that payload and uses triggers to decide when to load and mount the client module.

You can use mountly in two ways for islands:

  • As a dependencynpm install mountly and call mountIslandFeature() on DOM nodes that carry a data-mountly-island payload
  • Via custom elements — use defineMountlyFeature() to register widgets as <custom-element> tags (useful for unknown hosts, CMS pages, or third-party embeds)
<!-- Server renders the island HTML -->
<div
id="cart"
data-mountly-hydrated="true"
data-mountly-island='{
"schemaVersion":1,
"id":"cart-widget",
"moduleId":"cart",
"trigger":"click",
"props":{"items":3},
"skipIfHydrated":true
}'
>
<!-- SSR content, visible immediately -->
<div class="cart-summary">3 items, $42</div>
</div>
<script type="module">
import { mountIslandFeature } from 'mountly';
const loaders = {
cart: () => import('./cart-widget.js'),
};
mountIslandFeature(document.getElementById('cart'), loaders);
</script>

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

Control when JavaScript loads and runs.

TriggerWhen JS loadsBest for
clickOn first clickForms, modals, drawers
hoverOn hoverMenus, previews, tooltip-like widgets
focusOn focusInputs, search boxes, accessible controls
idleWhen browser is idle via requestIdleCallbackBelow-the-fold content, low-priority features
viewportWhen element enters viewportLazy sections, cards, comments, feeds
mediaWhen a media query matchesDevice/layout-specific UI
url-changeWhen URL/history changesSearch/filter panels, route-aware widgets
neverOnly if manually triggeredServer-rendered features that may never need JS

Islands can hydrate using different strategies: on page load, on user interaction (click, focus, hover), when entering the viewport, when the browser is idle, or when a media query matches. This flexibility lets you optimize when JavaScript runs based on user behavior and network conditions.

The island payload is the contract between the server-rendered HTML and mountly’s client runtime.

interface IslandPayload {
schemaVersion?: 1;
id: string;
moduleId: string;
targetSelector?: string;
props?: Record<string, unknown>;
trigger?: 'click' | 'hover' | 'focus' | 'viewport' | 'idle' | 'media' | 'url-change' | 'never';
preloadOn?: 'hover' | 'viewport' | 'idle' | 'media' | false;
skipIfHydrated?: boolean;
forceRemount?: boolean;
hydratedAttr?: string;
once?: boolean;
waitForParent?: boolean;
retry?: number;
retryDelayMs?: number;
requireSsrMarker?: boolean;
ssrMarkerAttr?: string;
version?: string;
moduleUrl?: string;
cssUrl?: string;
}
OptionDefaultWhat it controls
schemaVersion1 when generated by helpersPayload format version.
idRequiredStable island instance ID for events, warnings, and diagnostics.
moduleIdRequiredLoader key used to resolve the widget module.
targetSelectorIsland elementChild selector to mount into instead of replacing/mounting on the island root.
props{}Props forwarded to the widget module.
triggerclick in the feature layerActivation trigger; never intentionally leaves the SSR island inert.
preloadOnTrigger-dependentPreload trigger; use false to disable preloading.
skipIfHydratedtrueIf the hydrated marker is present, keep SSR content and skip client remount.
forceRemountfalseRemount even when SSR content is marked hydrated.
hydratedAttrdata-mountly-hydratedAttribute used to detect SSR/hydrated content.
oncefalseActivate only once.
waitForParenttrueFor nested islands, wait for a parent island before activating the child.
retry0Number of module-load retries after the first failure.
retryDelayMs0Delay between retries.
requireSsrMarkerfalseDo not activate unless the SSR marker attribute exists.
ssrMarkerAttrssrAttribute checked when requireSsrMarker is true.
versionUnused by runtimeOptional application/version metadata.
moduleUrlNoneWidget bundle URL forwarded to adapters so CSS can be derived automatically.
cssUrlDerived from moduleUrl by adaptersExplicit stylesheet URL forwarded to adapters.

mountIslandFeature(element, loaders, options) can override runtime behavior for a whole mount call:

Runtime optionWhat it controls
forceRemountOverride payload forceRemount.
skipIfHydratedOverride payload skipIfHydrated.
hydratedAttrOverride payload hydratedAttr.
onceOverride payload once.
waitForParentOverride payload waitForParent.
retryOverride payload retry.
retryDelayMsOverride payload retryDelayMs.
unmountEventEvent name that unmounts the island; set false to disable.
refreshEventEvent name that refreshes the island; set false to disable.
warnOnHydrationMismatchEnable/disable mismatch warnings when force-remounting hydrated content.
perfMarksEmit performance.mark() entries for island load/mount phases.
pauseOnHiddenPause behavior when document visibility changes.
requireSsrMarkerOverride payload requireSsrMarker.
ssrMarkerAttrOverride payload ssrMarkerAttr.

For many islands at once, mountAllIslands(document, loaders, { selector, forceRemount, skipIfHydrated, hydratedAttr }) scans the page and applies the same hydration policy to every matched payload.

Strategy 1: Preserve SSR content (default when marked hydrated)

Section titled “Strategy 1: Preserve SSR content (default when marked hydrated)”

If the element has the hydrated marker and skipIfHydrated is not changed, mountly leaves the server HTML alone.

<div
id="island"
data-mountly-hydrated="true"
data-mountly-island='{"schemaVersion":1,"id":"product","moduleId":"product-card","trigger":"click"}'
>
<a class="product-card" href="/products/42">View product</a>
</div>
<script type="module">
mountIslandFeature(island, loaders);
island.click(); // No client remount: SSR content is already marked hydrated.
</script>

Use when: Server-rendered HTML is complete and functional, such as product cards, links, summary panels, or read-only content.

Set skipIfHydrated: false when the server output is only a placeholder and the client component must take over on the trigger.

<div
id="island"
data-mountly-hydrated="true"
data-mountly-island='{
"schemaVersion":1,
"id":"counter",
"moduleId":"counter-widget",
"trigger":"click",
"skipIfHydrated":false
}'
>
<button>Open counter</button>
</div>
<script type="module">
mountIslandFeature(island, loaders);
island.click(); // Client render replaces the SSR placeholder.
</script>

Use when: The client needs state, event listeners, animation setup, browser APIs, or a different DOM shape from the server fallback.

Set forceRemount: true when the island is marked hydrated but you explicitly want a client remount anyway.

<div
id="island"
data-mountly-hydrated="true"
data-mountly-island='{
"schemaVersion":1,
"id":"counter",
"moduleId":"counter-widget",
"trigger":"click",
"forceRemount":true
}'
>
<div class="ssr">Loading counter...</div>
</div>
<script type="module">
mountIslandFeature(island, loaders);
island.click();
</script>

Use when: You want the payload to say “this has SSR content” while still forcing the client version to replace it. mountly warns about this path because mismatched server/client output can cause flicker.

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

<head>
<!-- Global stylesheet for no-JS fallback -->
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="island" data-mountly-island="...">
<span class="styled-widget">Server-rendered content</span>
</div>
<!-- Host script that mounts islands when JS is available -->
<script type="module" src="...">
</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 is similar in spirit to a <noscript> fallback, but the fallback is the actual server-rendered island content.

  • Small activation API: one function call around a server-rendered region
  • Framework-agnostic widgets: build islands in React, Vue, Svelte, TSRX, 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 provides hard style isolation (good for independent islands embedded in unknown hosts). Pick based on your needs, not as a limitation.
  • Islands are mostly independent — sharing React Context, query clients, or routers across multiple islands requires manual coordination (event bus, URL state, external store). This is by design, not a limitation.
  • Custom elements have form constraints (custom element approach only) — if you use defineMountlyFeature() to register custom elements, form integration is different. Use the dependency approach with direct mountIslandFeature() calls 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 are a better fit.
  • 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

A common confusion: islands architecture is often compared to microfrontends, but they solve different problems.

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

Microfrontends focus on independently developed and deployed application boundaries. They solve team autonomy, separate CI/CD pipelines, and cross-team ownership concerns—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”
ScenarioConsiderWhy
Content-first site (blogs, marketing, docs)AstroBuilt-in SSR, content collections, auto island generation, and integrated routing make this much simpler
Deno ecosystemFreshNative islands runtime support; no separate activation layer needed
Every region shares app stateFull-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 integrationFull-page appIf 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 deploymentMicrofrontendsIslands architecture does not inherently solve team boundaries, deployment independence, or cross-team ownership concerns

Representative outcomes for a React island:

BuildSize impactIncludes
Self-containedLargestReact + ReactDOM + adapter + component
Peer buildSmallerComponent + adapter, with framework runtime provided by host
SSR-only fallbackNo client JS for that islandServer-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.

Featuremountly IslandsAstroFreshFull-page SPA
Server-side renderingYou provide itBuilt inBuilt inUsually separate
Selective hydrationYesYesYesNo
Works without JSYes, if SSR fallback is meaningfulYesYesUsually no
UI framework choiceAdapter-basedMulti-framework integrationsPreact-firstApp choice
Host/build-tool agnosticYesNoNoDepends
Automatic island generationNoYesYesN/A
Best fitPortable activation layerContent sitesDeno islands appsDeep client apps