Dynamic islands

Static HTML.
Dynamic Components.

Some content shouldn't be in the prerendered HTML: cart state, recently viewed products, logged-in user widgets, etc. Islands let you punch those holes through the static page and fill them in the browser, after hydration. It's like a window to your app, within a static page.

Prerendered HTML is the same for every visitor. That's what makes it fast and crawlable. But some content is inherently per-visitor: what's in their cart, which pages they've seen, what they've favorited. You can't bake that into static HTML at build time.

Without islands
With islands
User-specific content
Skipped or deferred to a full re-render
Mounted into a placeholder after hydration
Crawler exposure
Dynamic content leaks into static HTML
Fallback text only. Component never runs at build time
Load timing
All or nothing on hydration
eager, visible, or idle per island
Hydration risk
Dynamic content causes SSR mismatch
Islands are outside the hydrated tree
01

Place a <pre-island> in your JSX

The custom element ships in the prerendered HTML as an inert placeholder. Crawlers see the fallback content inside it. React's renderToString passes unknown elements through unchanged.

html
<pre-island data-pre-island="cart-widget" data-pre-load="visible">
  <span class="island-loading">Loading cart...</span>
</pre-island>
02

Register the component in AppIslands.jsx

One file maps island names to React components. That's the entire registry. No config, no decorators, no build plugin.

src/AppIslands.jsx
import CartWidget from './islands/CartWidget.jsx'

export const islands = {
  'cart-widget': CartWidget,
}
03

mountIslands() does the rest

Called in main.jsx after hydrateRoot. Scans the DOM for pre-island elements, finds the matching component, and calls ReactDOM.createRoot(el).render() into each one. Each island is its own React root, independent of the main tree.

src/main.jsx
import { mountIslands } from './islands.js'
import { islands }      from './AppIslands.jsx'

// after hydrateRoot / createRoot:
mountIslands(islands)

The data-pre-load attribute controls when each island mounts. Match the strategy to the priority of the content.

eager

Immediate

Default. Mounts right after mountIslands() runs. Use for above-the-fold widgets that need to be interactive quickly.

visible

On scroll

Mounts when the element enters the viewport via IntersectionObserver. Use for below-the-fold content that isn't needed until the user reaches it.

idle

Background

Mounts via requestIdleCallback during browser downtime. Use for low-priority widgets that shouldn't compete with paint or interaction.

Islands don't receive props from the React tree.

Each island is a separate ReactDOM.createRoot, outside the main hydrateRoot tree. React context from the parent app doesn't reach them. Pass data via data attributes on the element itself, read from localStorage, or fetch from an API.

Island content is invisible to crawlers.

The component never runs during SSR. Crawlers see the fallback text inside the pre-island element. If you need dynamic content indexed, prerender it into a dedicated route instead.

Islands are not a replacement for a server.

They're browser-only. If your dynamic content requires auth, per-request data, or database access, you need edge SSR or an API endpoint. Islands handle client state, localStorage, and public fetches well. Everything else belongs server-side.

The widget below is a pre-island with data-pre-load="idle". It tracks which pages you've visited this session using sessionStorage. The prerendered HTML for this page contains none of this -- a fallback line is what the crawler sees. Navigate to a few pages and come back.

Right-click this page and choose View Page Source to see the raw prerendered HTML. Search for pre-island and you'll find the placeholder with the fallback text and nothing else. The session data below was never on the server.

Session data loads after hydration.