Skip to content

Astro With React: Islands and Partial Hydration

When Astro islands beat a full SPA, ship mostly static HTML, hydrate only the interactive pieces, and keep React where it pays off.

5 min read
Astro
React
Islands
Hydration
MPA
Frontend

Vite SSR fits when a handful of routes need HTML. Meta-frameworks fit when routing, data, and mutations are the product. Between those poles sits Astro with React islands.

Astro's default is an MPA that ships almost no client JavaScript. React (or Vue, Svelte, Solid) is opt-in per component. That is partial hydration with a name: only the islands you mark as interactive get a runtime. Everything else stays HTML.

When islands beat a full SPA

I reach for Astro + React when the product is mostly documents with pockets of app:

  • Marketing site with a pricing calculator, signup form, or live demo widget
  • Docs / blog with search, theme toggle, or interactive examples
  • Content-heavy product pages where SEO and first paint matter more than fake client-side routing

I do not reach for it when the product is the SPA: dense authenticated dashboards, multi-panel editors, keyboard-driven tools that keep local state across dozens of client routes. Those still want Vite CSR, Vite SSR, or a React meta-framework, covered in the framework landscape post.

Islands win when most of the page never needs React. Full SPA wins when most of the session is React.

What "island" actually means

An island is a client component boundary. Astro renders the surrounding page as static (or server) HTML. The island hydrates on its own schedule:

astro
--- // src/pages/pricing.astro import Layout from '../layouts/Layout.astro'; import PriceCalculator from '../components/PriceCalculator'; --- <Layout title="Pricing"> <h1>Pricing</h1> <p>Static copy. No React. Good.</p> <!-- Hydrate when visible, not on every page load by default --> <PriceCalculator client:visible /> </Layout>
tsx
// src/components/PriceCalculator.tsx import { useState } from 'react'; export default function PriceCalculator() { const [seats, setSeats] = useState(5); const monthly = seats * 12; return ( <form onSubmit={(e) => e.preventDefault()} aria-label="Seat price calculator" > <label> Seats <input type="number" min={1} value={seats} onChange={(e) => setSeats(Number(e.target.value))} /> </label> <p> <strong>${monthly}</strong> / month </p> </form> ); }

The hydration directives are the decision surface:

DirectiveWhen JS loadsUse when
client:loadImmediatelyAbove-the-fold interactive chrome
client:idleAfter requestIdleCallbackNice-to-have widgets
client:visibleWhen scrolled into viewBelow-fold calculators, demos
client:mediaWhen media query matchesMobile-only UI
client:onlyClient-only, skip SSRBrowser APIs you refuse to polyfill

Default (no directive) = zero client JS for that component. That is the feature, not a missing step.

Continuity from Vite SSR

Vite SSR keeps the SPA architecture and adds a server that can render routes. Astro flips the default: document-first, React as a guest.

ConcernVite SPA + SSRAstro + React islands
Default JSFull app runtimeNear-zero
NavigationClient routerMulti-page (or View Transitions)
Hydration scopeWhole tree (unless you carve)Explicit per island
Best fitApp with some public HTMLContent with some app

If the Astro decision tree said "I only needed HTML for three marketing URLs," Astro often closes that ticket without inventing a custom Express render path. If you already have a Vite app and need SSR for authenticated shells, Astro is the wrong migration, you are changing architecture, not flipping a switch.

Stack sketch that stays honest

js
// astro.config.mjs import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; export default defineConfig({ integrations: [react()], // Output: static by default; 'server' / hybrid when you need SSR routes });

React 19 is fine here in spring 2025, it shipped December 2024. I still keep island boundaries small. A giant client:load App shell is just an SPA with extra steps and a worse router story.

Failure modes I have paid for

Hydrating the whole page "for convenience." You deleted the reason you picked Astro. Audit client:* like you audit useEffect.

Sharing client state across islands without a plan. Islands are separate roots. Context does not magically span them. Pass props from Astro, use URL state, or accept a tiny shared store, do not invent a mini-SPA inside MDX.

Putting secrets in island props. Anything serialized into the island is public. Same rule as Vite VITE_*, coming later this quarter.

Expecting SPA navigation ergonomics. Multi-page is a feature for content. If your users live in one long session of client transitions, pick a framework that owns that model.

Ignoring the content collection / MDX pipeline. Astro shines when content is structured. Using it as "React with weird file names" wastes the MPA win.

Decision rule

text
Mostly static / content + a few interactive widgets? → Astro (or similar islands) + hydrate only those widgets Mostly interactive app + a few public SEO pages? → SPA/meta-framework; prerender or SSR the public routes Unsure? → Count client components. If >~30% of the UI must hydrate, you do not have an islands site, you have an app wearing a content costume.

Takeaway

Astro with React is partial hydration as a product decision: ship HTML by default, pay for React only where interaction lives. Use it when content is the product and widgets are guests, not when you wanted a SPA and hoped islands would forgive the architecture.