Deep dive into Astro's islands architecture, partial hydration, client directives, framework integration, and server islands.
Astro renders all components to static HTML on the server by default. Interactive components ("islands") are selectively hydrated on the client, shipping JavaScript only for the parts of the page that need interactivity.
Traditional SPA:
┌──────────────────────────────────────┐
│ Full JavaScript App │ ← All JS shipped
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │Nav │ │Hero│ │Card│ │Form│ │
│ └────┘ └────┘ └────┘ └────┘ │
└──────────────────────────────────────┘
Astro Islands:
┌──────────────────────────────────────┐
│ Static HTML (zero JS) │
│ ┌────┐ │
│ │Nav │ ← Island (client:load) │ ← Only island JS shipped
│ └────┘ │
│ ┌────────────┐ │
│ │ Hero Text │ ← Static HTML │ ← No JS
│ └────────────┘ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │Card│ │Card│ │Card│ ← Static │ ← No JS
│ └────┘ └────┘ └────┘ │
│ ┌──────┐ │
│ │ Form │ ← Island (client:visible) │ ← JS loaded on scroll
│ └──────┘ │
└──────────────────────────────────────┘
---
// This component renders to HTML on the server
// Then hydrates with React on the client
import SearchBar from '../components/SearchBar.tsx';
---
<!-- Static HTML (no JS) -->
<header>
<h1>My Site</h1>
<!-- React island - hydrates when visible -->
<SearchBar client:visible placeholder="Search docs..." />
</header>
Hydrates immediately when the page loads. Highest priority.
---
import AuthButton from '../components/AuthButton.tsx';
import NavMenu from '../components/NavMenu.tsx';
---
<!-- Use for: above-fold interactive elements that users interact with immediately -->
<AuthButton client:load />
<NavMenu client:load />
When to use:
When NOT to use:
client:visible)client:idle)Hydrates after the page has finished loading and requestIdleCallback fires.
---
import CommentSection from '../components/CommentSection.tsx';
import NewsletterSignup from '../components/NewsletterSignup.vue';
import ShareButtons from '../components/ShareButtons.tsx';
---
<!-- Use for: important but not immediately critical interactivity -->
<CommentSection client:idle postId={post.id} />
<NewsletterSignup client:idle />
<ShareButtons client:idle url={Astro.url} />
When to use:
Behavior:
requestIdleCallback (or setTimeout fallback after 200ms)client:visible)Hydrates when the element enters the viewport (IntersectionObserver).
---
import ImageCarousel from '../components/ImageCarousel.svelte';
import InteractiveChart from '../components/InteractiveChart.tsx';
import Testimonials from '../components/Testimonials.vue';
---
<!-- Use for: below-fold content that's only needed when scrolled to -->
<ImageCarousel client:visible images={gallery} />
<InteractiveChart client:visible data={chartData} />
<Testimonials client:visible />
<!-- With rootMargin - preload 200px before visible -->
<InteractiveChart client:visible={{rootMargin: "200px"}} data={chartData} />
When to use:
Behavior:
rootMargin option for preloadingHydrates only when a CSS media query matches.
---
import MobileMenu from '../components/MobileMenu.tsx';
import DesktopSidebar from '../components/DesktopSidebar.tsx';
import DarkModeToggle from '../components/DarkModeToggle.tsx';
---
<!-- Only hydrate on mobile -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- Only hydrate on desktop -->
<DesktopSidebar client:media="(min-width: 1024px)" />
<!-- Hydrate based on user preference -->
<DarkModeToggle client:media="(prefers-color-scheme: dark)" />
When to use:
Behavior:
Skips server rendering entirely. Component renders ONLY on the client.
---
import ThreeScene from '../components/ThreeScene.tsx';
import MapComponent from '../components/Map.tsx';
import CanvasEditor from '../components/CanvasEditor.svelte';
---
<!-- MUST specify the framework -->
<ThreeScene client:only="react" />
<MapComponent client:only="react" />
<CanvasEditor client:only="svelte" />
<!-- Valid framework values: -->
<!-- client:only="react" -->
<!-- client:only="preact" -->
<!-- client:only="vue" -->
<!-- client:only="svelte" -->
<!-- client:only="solid-js" -->
<!-- client:only="lit" -->
When to use:
windowBehavior:
Component renders to HTML only. Zero client-side JavaScript.
---
import Card from '../components/Card.astro';
import Footer from '../components/Footer.astro';
import BlogPostPreview from '../components/BlogPostPreview.astro';
---
<!-- Pure HTML, no JS ever -->
<Card title="Hello" description="World" />
<Footer />
<BlogPostPreview post={post} />
When to use:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import vue from '@astrojs/vue';
import svelte from '@astrojs/svelte';
import solid from '@astrojs/solid-js';
import preact from '@astrojs/preact';
export default defineConfig({
integrations: [
react({
include: ['**/react/**'], // Only process files in react/ dirs
}),
preact({
include: ['**/preact/**'], // Disambiguate from React
}),
vue(),
svelte(),
solid({
include: ['**/solid/**'],
}),
],
});
src/components/
├── react/ # React components (.tsx/.jsx)
│ ├── Counter.tsx
│ └── SearchBar.tsx
├── vue/ # Vue components (.vue)
│ ├── TodoList.vue
│ └── Modal.vue
├── svelte/ # Svelte components (.svelte)
│ ├── Carousel.svelte
│ └── Toggle.svelte
├── solid/ # Solid components (.tsx)
│ └── DataGrid.tsx
├── Header.astro # Astro (static)
└── Footer.astro
---
import ReactNav from '../components/react/NavBar.tsx';
import VueForm from '../components/vue/ContactForm.vue';
import SvelteCarousel from '../components/svelte/Carousel.svelte';
import Footer from '../components/Footer.astro';
---
<ReactNav client:load user={user} />
<main>
<h1>Multi-framework Page</h1>
<SvelteCarousel client:visible items={images} />
<VueForm client:idle endpoint="/api/contact" />
</main>
<Footer />
Islands are isolated by default. Here are patterns for sharing state.
npm install nanostores @nanostores/react @nanostores/vue @nanostores/svelte
// src/stores/cart.ts
import { atom, map, computed } from 'nanostores';
// Simple atom
export const isMenuOpen = atom(false);
// Map (object store)
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export const cartItems = map<Record<string, CartItem>>({});
// Computed (derived state)
export const cartTotal = computed(cartItems, (items) => {
return Object.values(items).reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
});
// Actions
export function addToCart(item: CartItem) {
const existing = cartItems.get()[item.id];
if (existing) {
cartItems.setKey(item.id, {
...existing,
quantity: existing.quantity + 1,
});
} else {
cartItems.setKey(item.id, { ...item, quantity: 1 });
}
}
export function removeFromCart(id: string) {
const items = { ...cartItems.get() };
delete items[id];
cartItems.set(items);
}
// React component using the store
import { useStore } from '@nanostores/react';
import { cartItems, cartTotal, addToCart } from '../stores/cart';
export function CartButton() {
const $items = useStore(cartItems);
const $total = useStore(cartTotal);
const count = Object.keys($items).length;
return (
<button>
Cart ({count}) - ${$total.toFixed(2)}
</button>
);
}
<!-- Svelte component using same store -->
<script>
import { cartItems, cartTotal } from '../stores/cart';
</script>
<div>
{#each Object.values($cartItems) as item}
<p>{item.name}: ${item.price} x {item.quantity}</p>
{/each}
<p>Total: ${$cartTotal.toFixed(2)}</p>
</div>
---
// For simple one-way communication between islands
---
<script>
// Dispatch from any island
document.dispatchEvent(new CustomEvent('cart:add', {
detail: { id: '123', name: 'Widget', price: 9.99 }
}));
// Listen in any island
document.addEventListener('cart:add', (e: CustomEvent) => {
console.log('Added to cart:', e.detail);
});
</script>
// Use URL search params for shareable state
function updateFilter(key: string, value: string) {
const url = new URL(window.location.href);
url.searchParams.set(key, value);
window.history.pushState({}, '', url);
// Notify other islands
document.dispatchEvent(new CustomEvent('url:change', {
detail: Object.fromEntries(url.searchParams),
}));
}
// astro.config.mjs - analyze bundle
import { defineConfig } from 'astro/config';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
vite: {
plugins: [
visualizer({
filename: './dist/stats.html',
gzipSize: true,
brotliSize: true,
}),
],
},
});
| Component Type | Target Size (gzipped) | Strategy |
|---|---|---|
| Critical island (client:load) | < 20 KB | Minimize dependencies |
| Deferred island (client:idle) | < 50 KB | Acceptable, loads after paint |
| Lazy island (client:visible) | < 100 KB | OK for rich interactive content |
| Full SPA island (client:only) | < 200 KB | Consider code splitting |
// 1. Prefer Preact over React for smaller islands
import preact from '@astrojs/preact';
// 2. Dynamic imports for heavy dependencies
const Chart = lazy(() => import('./HeavyChart'));
// 3. Tree-shakeable imports
import { format } from 'date-fns/format'; // Good: specific import
// import { format } from 'date-fns'; // Bad: imports everything
// 4. Use Astro's built-in Image optimization
import { Image } from 'astro:assets';
Server islands allow you to defer rendering of specific components to after the initial page response, enabling personalized content within cached pages.
Request Flow:
1. Edge/CDN serves cached static HTML instantly
2. Page displays with fallback content for server islands
3. Server islands fetch their content via separate requests
4. Dynamic content streams in and replaces fallbacks
┌─────────────────────────────────┐
│ Cached Static Page (CDN) │
│ │
│ ┌──────────────────────┐ │
│ │ Static Header │ │ ← Cached
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
│ │ server:defer │ │ ← Fetched separately
│ │ (user-specific data) │ │ after page load
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
│ │ Static Content │ │ ← Cached
│ └──────────────────────┘ │
└─────────────────────────────────┘
---
// src/components/UserGreeting.astro
const user = await getUser(Astro.cookies.get('session'));
---
<div>
<p>Welcome back, {user.name}!</p>
<p>You have {user.notifications} new notifications.</p>
</div>
---
// src/pages/index.astro
import UserGreeting from '../components/UserGreeting.astro';
import ProductRecommendations from '../components/ProductRecommendations.astro';
---
<html>
<body>
<h1>Welcome to our Store</h1>
<!-- This component renders on the server AFTER the page is sent -->
<UserGreeting server:defer>
<!-- Fallback shown while server island loads -->
<p slot="fallback">Loading your profile...</p>
</UserGreeting>
<!-- Another server island -->
<ProductRecommendations server:defer>
<div slot="fallback" class="skeleton-grid">
<!-- Skeleton placeholder -->
</div>
</ProductRecommendations>
<!-- This is static, served from cache -->
<footer>Static footer content</footer>
</body>
</html>
---
// Server islands can receive serializable props
import PricingTable from '../components/PricingTable.astro';
---
<!-- Props are encrypted and sent with the deferred request -->
<PricingTable
server:defer
productId="abc123"
region={Astro.locals.region}
>
<p slot="fallback">Loading pricing...</p>
</PricingTable>
// astro.config.mjs
export default defineConfig({
output: 'server',
adapter: cloudflare(),
});
---
// src/pages/product/[id].astro
// The page itself can be cached aggressively
Astro.response.headers.set('Cache-Control', 'public, max-age=3600');
import ProductDetails from '../components/ProductDetails.astro';
import UserReviews from '../components/UserReviews.astro';
import AddToCart from '../components/AddToCart.astro';
---
<!-- Static product info (cached) -->
<ProductDetails productId={Astro.params.id} />
<!-- Dynamic, personalized (server island) -->
<AddToCart server:defer productId={Astro.params.id}>
<button slot="fallback" disabled>Loading...</button>
</AddToCart>
<!-- Dynamic, frequently updated (server island) -->
<UserReviews server:defer productId={Astro.params.id}>
<p slot="fallback">Loading reviews...</p>
</UserReviews>
---
import ReactAccordion from '../components/react/Accordion.tsx';
---
<!-- Astro content becomes children in React -->
<ReactAccordion client:visible title="FAQ">
<p>This HTML is passed as children to the React component.</p>
<ul>
<li>Static content rendered by Astro</li>
<li>Hydrated by React when visible</li>
</ul>
</ReactAccordion>
// React component receiving Astro slot content
interface AccordionProps {
title: string;
children: React.ReactNode; // Astro slot content arrives as children
}
export function Accordion({ title, children }: AccordionProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
}
---
import ReactCard from '../components/react/Card.tsx';
---
<!-- Named slots map to props in React -->
<ReactCard client:idle>
<h2 slot="header">Card Title</h2>
<p>Default slot content (becomes children)</p>
<span slot="footer">Card footer</span>
</ReactCard>
// React component with named slots
interface CardProps {
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
export function Card({ header, footer, children }: CardProps) {
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
---
import ReactWrapper from '../components/react/Wrapper.tsx';
import SvelteWidget from '../components/svelte/Widget.svelte';
---
<!-- Nested islands hydrate independently -->
<ReactWrapper client:load>
<!-- This Svelte component hydrates separately from the React wrapper -->
<SvelteWidget client:visible count={5} />
</ReactWrapper>
Important: Nested islands are NOT nested in the JavaScript sense. Each island hydrates independently. The React wrapper doesn't "own" the Svelte widget - they just happen to be visually nested in the HTML.
---
import HeavyEditor from '../components/react/Editor.tsx';
const isEditor = Astro.url.searchParams.has('edit');
---
<!-- Only include the island if editing -->
{isEditor ? (
<HeavyEditor client:load content={content} />
) : (
<div class="content" set:html={renderedContent} />
)}
// React island with built-in loading state
import { useState, useEffect } from 'react';
export function DataWidget({ endpoint }: { endpoint: string }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(endpoint)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); });
}, [endpoint]);
if (loading) return <div class="skeleton" />;
return <div>{/* render data */}</div>;
}
// React island that reinitializes on View Transition navigation
import { useEffect } from 'react';
export function PageTracker() {
useEffect(() => {
// This runs on initial hydration AND after View Transition navigations
const handler = () => {
console.log('Page changed:', window.location.pathname);
};
document.addEventListener('astro:page-load', handler);
return () => document.removeEventListener('astro:page-load', handler);
}, []);
return null;
}