Islands & Hydration
Astro renders your entire page to static HTML on the server and ships zero JavaScript by default. When you need interactivity — a search box, a cart counter, a carousel — you opt in to it locally with an island: a self-contained framework component that Astro hydrates on the client independently of the rest of the page. This “islands architecture” is the core of why Astro sites stay fast: the static shell loads instantly, and only the interactive bits cost any JS.
What an island actually is
An island is any UI framework component (React, Preact, Vue, Svelte, Solid, or even a raw .astro component) that you mark for client-side hydration. Everything outside the island remains inert HTML. The page is rendered once on the server; each island then becomes a tiny isolated app that boots on its own, with its own JS bundle, on its own schedule.
The key mental model: static by default, interactive by exception. You are not shipping a single-page application that takes over the whole document. You are sprinkling small interactive widgets into a sea of HTML.
---
// src/pages/index.astro
import Counter from '../components/Counter.jsx';
import Header from '../components/Header.astro';
---
<html lang="en">
<body>
<Header /> <!-- static HTML, no JS -->
<main>
<h1>Welcome</h1>
<Counter client:load /> <!-- an interactive island -->
</main>
</body>
</html>
Here Header ships no JavaScript at all. Counter is an island: its HTML is rendered on the server, and because of client:load its JS is also sent and hydrated in the browser.
Enabling a UI framework
Islands require a framework integration. Add one with the CLI, which wires up the integration and installs peer dependencies for you.
npx astro add react
Output:
✔ Resolving packages...
Astro will add the following dependency: @astrojs/react
✔ Continue? … yes
✔ Adding integrations...
✔ Successfully added the following integration to your project: @astrojs/react
This updates astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
});
You can mix frameworks freely — a Vue island and a React island can coexist on the same page, each with its own runtime, because each is hydrated in isolation.
The client:* directives
A component only becomes an island when you add a client:* directive. The directive controls when and whether hydration happens. Without one, the component is rendered to HTML and stays static.
| Directive | When it hydrates | Typical use |
|---|---|---|
client:load | Immediately on page load | Critical above-the-fold UI |
client:idle | When the browser is idle | Low-priority widgets |
client:visible | When it scrolls into view | Below-the-fold components |
client:media | When a media query matches | Mobile-only / desktop-only UI |
client:only | Skips server render; client only | Components that can’t SSR |
---
import Carousel from '../components/Carousel.svelte';
import Newsletter from '../components/Newsletter.vue';
---
<Carousel client:visible />
<Newsletter client:idle />
Tip: Choose the laziest directive that still feels instant to the user.
client:visibleandclient:idledefer JS execution, keeping your initial load lean. Reserveclient:loadfor things that must be interactive the moment the page paints.
How hydration works under the hood
When Astro builds the page it does three things for each island:
- Renders the component to HTML on the server and inlines that markup.
- Emits a small loader script and the component’s JS bundle.
- Wraps the island in an
<astro-island>custom element carrying the props and the directive.
The loader watches for the directive’s trigger (load, idle, intersection, media match). When it fires, it dynamically imports the framework runtime plus the component and hydrates only that island. Other islands sit untouched until their own triggers fire. This is why a page with ten islands does not download one giant bundle — each island’s JS is requested independently.
Passing props and children
Islands receive serializable props from the surrounding .astro file. Props are JSON-serialized into the <astro-island> element, so pass plain data — not functions or class instances.
---
import StarRating from '../components/StarRating.jsx';
const product = { id: 'sku-42', rating: 4.5 };
---
<StarRating client:visible value={product.rating} max={5} />
You can also pass static HTML as children via slots. The slotted content is rendered on the server and handed to the island as children, so even the markup inside an island can be zero-JS until hydration.
Islands vs. a full SPA
The contrast with a traditional single-page app is the whole point.
| Aspect | Islands (Astro) | Full SPA |
|---|---|---|
| Default JS | None | Entire app bundle |
| Initial HTML | Fully rendered | Often empty shell |
| Hydration scope | Per-component | Whole app at once |
| Time to interactive | Fast, incremental | Blocked on full bundle |
Gotcha: State is not shared between islands automatically. Two React islands are two separate React apps. To share state, use nano stores, the URL,
localStorage, or custom events — not a single top-level context provider.
Best Practices
- Keep islands small and focused; hydrate widgets, not entire layouts.
- Prefer
client:visibleorclient:idleoverclient:loadunless interactivity must be immediate. - Pass only serializable props — no functions, class instances, or DOM nodes.
- Use
.astrocomponents (which are always zero-JS) for anything that doesn’t need interactivity. - Reserve
client:onlyfor components that genuinely cannot render on the server, since it sacrifices SSR HTML and SEO content. - Share cross-island state with lightweight stores or browser events rather than a global framework provider.