Astro Islands Architecture: When to Use Partial Hydration
I've shipped enough full SPAs to know their dirty secret: they're overly expensive for most websites. A marketing site doesn't need React hydrating a footer. A documentation portal doesn't need Vue managing a navigation bar. Astro's Islands architecture fixed this for me, and I'm not going back.
The Islands pattern is simple: ship static HTML by default, hydrate only the interactive pieces. You get the performance of a static site with the interactivity of a modern app. In practice, this means your Lighthouse scores stop being a source of shame.
The Problem with Full Hydration
Before Islands, I had two choices:
- Pure static site – fast, but adding a single interactive dropdown meant rebuilding and redeploying.
- Full SPA – interactive everywhere, but shipping 150kb of JavaScript for a testimonial carousel.
I'd reach for the SPA because I couldn't handle the deployment friction. Then I'd watch Core Web Vitals tank. The irony: most of the page didn't need JavaScript at all.
CitizenApp's dashboard started this way. The sidebar navigation was a full React component. The settings panel was a full React component. The static content blocks? Also React. We were hydrating ~40% of the page that never changed state. Shipping 180kb of JavaScript for what could've been 20kb.
Astro Islands solved this by inverting the default: everything is static unless you explicitly say otherwise.
How Astro Islands Actually Work
When you mark a component as interactive using the client: directive, Astro:
- Renders it to static HTML on the server
- Bundles only that component's JavaScript
- Ships the minimal code needed to hydrate it in the browser
- Leaves everything else as plain HTML
Here's a real example from CitizenApp's dashboard:
---
// src/pages/dashboard.astro
import StaticHeader from '../components/Header.astro';
import InteractiveChart from '../components/Chart.tsx';
import StaticFooter from '../components/Footer.astro';
---
<Layout>
<StaticHeader />
<InteractiveChart client:load data={chartData} />
<StaticFooter />
</Layout>
The client:load directive tells Astro: "This needs JavaScript. Ship it." The Header and Footer remain pure HTML.
The client: directives matter:
-
client:load– Hydrate immediately on page load. Use for above-the-fold interactivity. -
client:idle– Hydrate when the browser is idle (requestIdleCallback). Use for below-the-fold features. -
client:visible– Hydrate only when the element enters the viewport. Use for modals, tooltips, lazy-loaded sections. -
client:only– Skip server rendering, only hydrate on client. Avoid this; it kills SEO.
Decision Tree: Static vs. Island vs. SPA
Here's my mental model for every page I build:
Is the entire page interactive with frequently-changing state? → Full SPA (use Next.js, Remix)
Are there isolated interactive regions with other static content? → Islands (use Astro)
Is the page mostly static with minimal interactivity? → Static + one Island (use Astro)
Is it completely static? → Pure HTML (use Astro)
For CitizenApp, the dashboard is Islands. The settings page is mostly static with an interactive form Island. The marketing site is pure static.
Real Layout Examples
Example 1: Marketing Site with Island
---
// src/pages/pricing.astro
import PricingTable from '../components/PricingTable.tsx';
---
<html>
<head>
<title>Pricing</title>
</head>
<body>
<!-- Static hero -->
<section class="hero">
<h1>Simple, transparent pricing</h1>
<p>No surprises. Cancel anytime.</p>
</section>
<!-- Interactive pricing table Island -->
<PricingTable client:visible plans={plans} />
<!-- Static FAQ -->
<section class="faq">
{faqItems.map(item => (
<details>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
))}
</section>
</body>
</html>
The hero and FAQ are static HTML. The pricing table is the only Island. Users with JavaScript disabled still see the static sections. The JavaScript bundle is ~15kb instead of 80kb.
Example 2: Dashboard with Multiple Islands
---
// src/pages/dashboard/overview.astro
import Sidebar from '../components/Sidebar.astro';
import Navbar from '../components/Navbar.astro';
import DataGrid from '../components/DataGrid.tsx';
import LineChart from '../components/LineChart.tsx';
import StaticStats from '../components/Stats.astro';
---
<Layout>
<Sidebar /> {/* Static */}
<Navbar /> {/* Static */}
<main>
<StaticStats data={stats} /> {/* Static */}
<LineChart client:load data={monthlyData} /> {/* Island */}
<DataGrid client:visible rows={users} /> {/* Island */}
</main>
</Layout>
Static components are compiled away. Islands are bundled separately with their dependencies. A user on a slow 3G connection downloads the page structure immediately, then hydrates the chart and grid as bandwidth allows.
Performance Impact: Real Numbers
On CitizenApp's marketing site, I measured switching from a full React app to Astro Islands:
- JavaScript bundle: 180kb → 35kb
- First Contentful Paint: 2.1s → 0.8s
- Largest Contentful Paint: 3.2s → 1.4s
- Cumulative Layout Shift: 0.15 → 0.02
The static header, footer, testimonials, and FAQ rendered immediately. Only the contact form hydrated. Users felt the difference.
Gotcha: Framework-Agnostic Hydration
Here's what burned me: I tried using a React Island with Redux state management, then added a separate Vue Island below it. They hydrated independently, couldn't share state, and I had duplicate data fetching.
Astro Islands are isolated. If you need shared state across Islands, you have two options:
- Use Web APIs – localStorage, custom events, or a shared data attribute
- Accept that you need more than Islands – and migrate to a full framework
For CitizenApp, I use Web APIs. The authentication Island sets localStorage with the user token. Other Islands read it. Lightweight and framework-agnostic.
// src/components/AuthIsland.tsx
export default function AuthIsland() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const stored = localStorage.getItem('user');
if (stored) setUser(JSON.parse(stored));
}, []);
const handleLogin = async (credentials) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
const userData = await response.json();
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
};
return <LoginForm onSubmit={handleLogin} />;
}
Other Islands read from localStorage instead of fetching again.
What I Missed Early
I initially thought Islands were only for "mostly static" sites. I underestimated how much of a dashboard is actually static. The page shell, layout, headers, sidebar navigation—none of it changes without a full page reload. Only the data grid, charts, and forms need to be Islands.
I also didn't appreciate the deployment simplicity. With Astro on Cloudflare Pages, my builds are 30 seconds. No server rendering complexity. Everything pre-renders to static HTML at build time (with Islands hydrating on demand).
When NOT to Use Islands
If your page is a canvas editor, collaborative spreadsheet, or real-time multiplayer experience—use a full SPA. Islands add cognitive overhead when the entire page is inherently interactive. But for 90% of web projects? Marketing sites, dashboards, content platforms, admin panels? Islands win.
Ship less JavaScript. Your users' devices will thank you, and your Lighthouse scores will finally make sense.










