Islands Architecture: Partial Hydration for Faster Pages
Server-render the page as static HTML and hydrate only the interactive islands. Covers Astro client directives, partial hydration, shared state, and when islands beat a SPA.
Overview
Your page probably ships JavaScript it never uses. In a traditional SPA or a fully hydrated Next.js app, the server sends HTML, then the browser downloads the entire component tree as JavaScript and rebuilds the same DOM again just to attach event handlers. On a content-heavy page where 90% of the markup is text, images, and links, that’s a lot of bytes spent re-rendering what the user could already see.
Islands architecture flips the default. The page is server-rendered to static HTML, and only the components that genuinely need interactivity — the “islands” — get hydrated on the client. Everything else stays as zero-JavaScript markup. A blog post with a search box ships JavaScript for the search box, not for the article, the sidebar, the footer, and the ad slots.
Astro popularized the pattern with its client directives (client:load, client:visible, client:idle), but the idea is older and broader. Jason Miller and Katie Sylor-Miller documented it on web.dev in 2020, Marko had been doing partial hydration for years, and today Fresh (Deno), Elder.js, îles, and Qwik’s resumability all riff on the same premise: most of a content page doesn’t need a JavaScript runtime.
When to Use
- Content-heavy sites where most of the page is static: blogs, docs, e-commerce catalogs, marketing pages
- Pages where only a few components need interactivity: a search box, a cart counter, a newsletter form
- Performance-critical pages where every kilobyte of JavaScript costs you real users on slow devices
- SEO-focused sites that need fast First Contentful Paint and Time to Interactive
- Multi-framework pages where different widgets were built in React, Svelte, and Vue and a rewrite isn’t happening
- Pages where SEO must see fully rendered content but small parts still need rich client behavior
When NOT to Use
- Highly interactive apps (dashboards, editors, chat) where most of the page is interactive anyway; you’ve just re-invented a SPA with extra steps
- Single-page applications with complex client-side routing and shared state across every view
- Pages where the entire surface needs hydration immediately (a collaborative document editor, say)
- Projects without SSR or SSG: islands need server-rendered HTML to exist at all
- Teams that can’t tolerate two mental models; you’ll debug server render and client hydration as separate concerns
Solution
Astro islands with client directives
---
// page.astro — Astro page with islands
import SearchBox from '../components/SearchBox.jsx';
import Comments from '../components/Comments.jsx';
import NewsletterForm from '../components/NewsletterForm.jsx';
import ProductGallery from '../components/ProductGallery.jsx';
const products = await fetchProducts();
const post = await fetchPost(Astro.params.slug);
---
<html>
<body>
<header>
<nav>
<!-- Static navigation — zero JS -->
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/about">About</a>
</nav>
<!-- Island: hydrate immediately (search needs to be interactive right away) -->
<SearchBox client:load />
</header>
<main>
<h1>Our Products</h1>
<!-- Static product grid — zero JS, server-rendered HTML -->
<div class="product-grid">
{products.map(product => (
<article class="product-card">
<img src={product.image} alt={product.name} loading="lazy" />
<h2>{product.name}</h2>
<p>{product.price}</p>
</article>
))}
</div>
<!-- Island: hydrate when visible in viewport (gallery is below the fold) -->
<ProductGallery products={products} client:visible />
<!-- Island: hydrate when browser is idle (comments are non-critical) -->
<Comments postId={post.id} client:idle />
</main>
<footer>
<!-- Island: hydrate on idle (form doesn't need to be interactive immediately) -->
<NewsletterForm client:idle />
</footer>
</body>
</html>
Client directives explained
---
// client-directives.astro — all hydration strategies
import InteractiveComponent from '../components/InteractiveComponent.jsx';
---
<!-- client:load — hydrate immediately on page load -->
<!-- Use for: above-the-fold interactive elements that must work right away -->
<SearchBox client:load />
<!-- client:visible — hydrate when the component scrolls into viewport -->
<!-- Use for: below-the-fold components, image galleries, comment sections -->
<ProductGallery client:visible />
<!-- client:idle — hydrate when the browser is idle (requestIdleCallback) -->
<!-- Use for: non-critical interactive elements, forms, widgets -->
<NewsletterForm client:idle />
<!-- client:media — hydrate when a media query matches -->
<!-- Use for: responsive components that only need JS on certain screens -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- client:only — skip SSR, render only on the client -->
<!-- Use for: components that depend on browser APIs (window, document) -->
<Chart client:only="react" />
React island in an Astro page
// SearchBox.jsx — React component used as an island
import { useState, useEffect } from 'react';
export default function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data);
setIsOpen(true);
}, 200);
return () => clearTimeout(timer);
}, [query]);
return (
<div className="search-box">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
/>
{isOpen && results.length > 0 && (
<ul className="search-results">
{results.map(result => (
<li key={result.id}>
<a href={result.url}>{result.title}</a>
</li>
))}
</ul>
)}
</div>
);
}
Passing server data to islands
---
// product-page.astro — pass server-fetched data to an island
import ProductConfigurator from '../components/ProductConfigurator.jsx';
const product = await fetchProduct(Astro.params.slug);
const variants = await fetchVariants(product.id);
const inventory = await fetchInventory(product.id);
---
<ProductConfigurator
client:visible
product={product}
variants={variants}
inventory={inventory}
/>
// ProductConfigurator.jsx — receives serialized props from server
import { useState } from 'react';
export default function ProductConfigurator({ product, variants, inventory }) {
const [selectedVariant, setSelectedVariant] = useState(variants[0]);
const [quantity, setQuantity] = useState(1);
const inStock = inventory[selectedVariant.id] >= quantity;
return (
<div className="configurator">
<h2>{product.name}</h2>
<div className="variants">
{variants.map(variant => (
<button
key={variant.id}
onClick={() => setSelectedVariant(variant)}
className={selectedVariant.id === variant.id ? 'selected' : ''}
>
{variant.name}
</button>
))}
</div>
<div className="quantity">
<button onClick={() => setQuantity(q => Math.max(1, q - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(q => q + 1)}>+</button>
</div>
<button disabled={!inStock}>
{inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
</div>
);
}
Multiple framework islands on one page
---
// multi-framework.astro — React, Svelte, and Vue islands on the same page
import ReactWidget from '../components/ReactWidget.jsx';
import SvelteCounter from '../components/SvelteCounter.svelte';
import VueChart from '../components/VueChart.vue';
---
<div>
<h1>Multi-framework page</h1>
<!-- React island -->
<ReactWidget client:visible />
<!-- Svelte island -->
<SvelteCounter client:load />
<!-- Vue island -->
<VueChart data={chartData} client:visible />
</div>
Island with shared state
---
// cart-page.astro — islands sharing state via a store
import CartItems from '../components/CartItems.jsx';
import CartSummary from '../components/CartSummary.jsx';
import { cartStore } from '../stores/cart.ts';
---
<!-- Initialize the store with server data -->
<script define:vars={{ initialCart }}>
window.__cartStore = initialCart;
</script>
<CartItems client:load store={cartStore} />
<CartSummary client:visible store={cartStore} />
// CartItems.jsx — React island reading from shared store
import { useSyncExternalStore } from 'react';
export default function CartItems({ store }) {
const items = useSyncExternalStore(
store.subscribe,
() => store.getSnapshot(),
() => store.getServerSnapshot()
);
const removeItem = (id) => {
store.removeItem(id);
};
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} — ${item.price}
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
Performance comparison
// Performance impact of islands vs. full hydration
// (illustrative numbers — measure your own bundle)
//
// Full hydration (Next.js SPA):
// - Page HTML: 50KB
// - JavaScript bundle: 350KB (React + app code + components)
// - Time to Interactive: 3.2s
// - FID: 120ms
//
// Islands architecture (Astro):
// - Page HTML: 55KB (slightly more — includes rendered component HTML)
// - JavaScript bundle: 45KB (only the interactive islands)
// - Time to Interactive: 1.1s
// - FID: 25ms
//
// The 305KB JavaScript savings come from not shipping
// React runtime, router, and non-interactive component code.
How It Works
The lifecycle has three phases. First, the server renders the complete page, including the islands, to HTML, so the browser paints real content on first paint with no client-side rendering pass. Second, the server serializes each island’s props into the markup (Astro embeds them as attributes on a custom element), along with a small script that knows which directive to honor. Third, a tiny runtime walks the page and hydrates each island according to its directive: client:load right away, client:visible through an IntersectionObserver, client:idle through requestIdleCallback.
That serialization step is the constraint people hit first. Props crossing the server→client boundary must be JSON-serializable: no functions, no class instances, no streaming data. Dates arrive as strings; Maps become objects. It also means every island effectively renders twice: once on the server for the initial HTML, once on the client when hydration attaches event listeners and state. Framework authors call this the “double render” cost, and it’s why islands are a bad fit when most of the page is interactive anyway — you’d pay the server render and the full hydration.
The other real trade-off is shared state. Islands are isolated roots: a React island and a Svelte island on the same page can’t just import each other’s context. Astro’s answer is nanostores or a custom store serialized onto window, which works for small state like a cart but becomes awkward as the shared surface grows. When you find yourself passing callbacks between five islands, the page wants to be a SPA and the pattern is fighting you.
Worth noting where the term came from: Marko’s team had partial hydration working years before it had a name, Jason Miller and Katie Sylor-Miller wrote it up as “islands architecture” on web.dev in 2020, and the idea has since branched into resumability (Qwik serializes the entire app state into HTML so hydration never re-runs component code at all) and streaming server components (React Server Components, which push the static/interactive split into the component tree rather than the page).
A practical heuristic before adopting the pattern: audit the page you’re building and mark every component that actually needs a click handler, local state, or a subscription. If the marks cover less than a quarter of the page, islands will almost certainly ship less JavaScript than the alternative — and the remaining three quarters gets to be boring, cacheable HTML that works even when the JavaScript fails to load.
Variants
Islands with hydration streaming
---
// streaming-islands.astro — progressive enhancement with defer
import HeavyChart from '../components/HeavyChart.jsx';
import DataTable from '../components/DataTable.jsx';
---
<!-- Static content renders immediately -->
<section>
<h1>Dashboard</h1>
<p>Sales overview for Q4 2026</p>
</section>
<!-- Island loads when visible — chart is below the fold -->
<HeavyChart client:visible data={chartData} />
<!-- Island loads on idle — table is heavy but not urgent -->
<DataTable client:idle rows={tableData} />
Nested islands
---
// nested-islands.astro — island within a static layout
import Sidebar from '../components/Sidebar.jsx';
import FilterPanel from '../components/FilterPanel.jsx';
---
<!-- Sidebar is mostly static but contains an interactive filter panel -->
<Sidebar>
<nav>
<a href="/category/all">All Products</a>
<a href="/category/electronics">Electronics</a>
</nav>
<!-- Only this part hydrates -->
<FilterPanel client:visible filters={availableFilters} />
</Sidebar>
Frameworks that implement islands
| Framework | Runtime | Approach |
|---|---|---|
| Astro | Node | client:* directives on any UI framework component |
| Fresh | Deno | Islands in islands/ directory, Preact only |
| Marko | Node | The original partial-hydration framework, predates the name |
| Elder.js | Node | Svelte-focused SSG with hydrate-client |
| îles | Node | Vue-focused, Vite-based |
| Qwik | Node | Resumability — serializes app state, no re-execution on client |
Astro is the most flexible because it’s framework-agnostic: you pay for one runtime per framework you actually use on the page. Fresh and îles are smaller but lock you to Preact and Vue respectively. Qwik isn’t islands in the strict sense (every component can resume independently), but it solves the same problem from a different angle.
Best Practices
-
For a deeper guide, see Suspense Boundary: Declarative Loading States with React. Islands pair naturally with Progressive Enhancement — the static HTML should work before hydration even starts.
-
Use
client:visibleby default: most interactive components are below the fold and don’t need immediate hydration -
Use
client:loadonly for above-the-fold critical interactions: search boxes, navigation menus -
Use
client:idlefor non-critical widgets: newsletter forms, recommendation carousels -
Keep islands small: a 5KB island is better than a 50KB island. Extract only the interactive part
-
Pass server data as props: fetch data on the server and pass it to islands as serialized props
-
Minimize shared state between islands: each island should be self-contained when possible
-
Use
client:onlyfor browser-API-dependent components: maps, charts that needwindoworcanvas -
Profile the JavaScript bundle: check that non-interactive parts aren’t accidentally hydrated
Common Mistakes
- Hydrating everything: wrapping the whole page in one
client:loadisland. This defeats the purpose — you’re back to full hydration. - Using
client:loadby default: most components don’t need immediate hydration. Useclient:visibleorclient:idleinstead. - Large islands: a single 100KB island with 20 sub-components. Split into smaller islands so only the visible one hydrates.
- Not passing server data: fetching data inside the island on the client. Fetch on the server and pass as props to avoid waterfalls.
- Sharing state across islands via global variables: fragile and hard to debug. Use a proper state management solution if islands need to communicate.
- Forgetting the serialization boundary: passing functions, class instances, or Dates as props. Everything crossing to an island goes through JSON — a
Datearrives as a string, aMapas a plain object.
See Also
- Astro islands documentation: official guide to client directives and partial hydration
- Islands architecture on web.dev: Jason Miller’s write-up of the pattern that popularized the name
- Fresh (Deno): islands-first framework where interactivity is opt-in per directory
- Progressive Enhancement: Layered Functionality Without JavaScript: islands degrade gracefully only if the static HTML works on its own
- Suspense Boundary: Declarative Loading States with React: the server-component cousin of selective hydration
Companion code: islands architecture examples: runnable versions of the snippets on this page.
Frequently Asked Questions
What is islands architecture?
A pattern where a page is server-rendered as static HTML, and only specific interactive components ("islands") are hydrated on the client. The rest of the page ships zero JavaScript. Astro is the most popular framework implementing this pattern.
How is this different from code-splitting?
Code-splitting splits JavaScript into chunks that load on demand. Islands architecture goes further — the static parts of the page never load any JavaScript at all. Only the interactive islands ship JS.
How is it different from full hydration?
In a fully hydrated app (a typical Next.js SPA), the server sends HTML and the browser downloads the entire component tree as JavaScript to rebuild the same DOM and attach event handlers. With islands, only the interactive components hydrate — the article, sidebar, and footer never ship a line of JS.
Which client directive should I use?
Use client:load for above-the-fold critical interactions, client:visible for below-the-fold components, client:idle for non-critical widgets, and client:media for responsive-only components.
Can I use multiple frameworks on the same page?
Yes. Astro supports React, Svelte, Vue, Solid, and others as islands on the same page. Each island is independent and only its framework runtime is shipped — so three frameworks means three runtimes, which is itself a cost worth weighing.
Does islands architecture work with client-side routing?
Not naturally. Islands are designed for multi-page apps (MPA) where each page is server-rendered. For client-side routing with partial hydration, you'd need a custom solution or a framework like Fresh (Deno) that combines islands with client-side navigation.
Related Resources
Progressive Enhancement
Build a functional HTML baseline and add CSS and JavaScript layers on top. Covers feature detection, graceful degradation, and no-JS fallbacks.
PatternSuspense Boundary: Declarative Loading States with React
How to use React Suspense boundaries for declarative loading states. Covers data fetching, streaming SSR, nested boundaries, and error boundaries.
GuideBundle Size Optimization: A Practical Frontend Guide
A hands-on guide to measuring and reducing JavaScript bundle size with tree shaking, code splitting, dependency swaps, compression, and CI budgets.
DocFrontend Performance Budget Template
A template for defining JS, CSS, image, and font budgets per route — with a method for picking the numbers, CI enforcement, and production monitoring thresholds.
DocCore Web Vitals Audit Checklist
Checklist for auditing Core Web Vitals per page: LCP optimization steps, INP interaction tuning, CLS layout stability fixes, field data vs lab data analysis, CrUX integration, and remediation tracking with code examples for images, fonts, JavaScript, and CSS.