Islands Architecture: Interactive Islands in Static Seas
Islands Architecture is a rendering pattern where you have static HTML with interactive "islands" of JavaScript. The static content loads instantly, while interactive components hydrate independently, providing excellent performance and user experience.
How Islands Architecture Works
Islands vs Traditional SPA
When to Use Islands Architecture
Perfect For:
- Content-Heavy Sites: Blogs, documentation, marketing sites
- E-commerce: Product pages with static content and interactive features
- News Sites: Articles with static content and dynamic widgets
- Portfolio Sites: Static content with interactive galleries or forms
- Landing Pages: Marketing pages with interactive CTAs and forms
- Documentation: Static content with interactive code examples
Not Ideal For:
- Highly Interactive Apps: Dashboards, social media feeds
- Real-time Applications: Chat apps, live collaboration tools
- Complex State Management: Applications with heavy client-side state
Implementation with Astro
Basic Astro Page with Islands
// src/pages/blog-post.astro
import BlogHeader from '../components/BlogHeader.astro'
import BlogContent from '../components/BlogContent.astro'
import CommentSection from '../components/CommentSection.tsx'
import ShareButtons from '../components/ShareButtons.tsx'
import RelatedPosts from '../components/RelatedPosts.astro'
// Fetch data at build time
const post = await fetch('https://api.example.com/posts/123').then(res => res.json())
const relatedPosts = await fetch('https://api.example.com/posts/123/related').then(res => res.json())
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{post.title}</title>
</head>
<body>
<!-- Static content - rendered at build time -->
<BlogHeader title={post.title} author={post.author} publishedAt={post.publishedAt} />
<main class="max-w-4xl mx-auto py-8 px-4">
<article>
<!-- Static content -->
<BlogContent content={post.content} />
<!-- Interactive island - hydrates on client -->
<CommentSection client:load postId={post.id} />
<!-- Interactive island - hydrates on visible -->
<ShareButtons client:visible postUrl={post.url} />
</article>
<!-- Static content -->
<RelatedPosts posts={relatedPosts} />
</main>
</body>
</html>
Static Components (No JavaScript)
// src/components/BlogHeader.astro
interface Props {
title: string
author: {
name: string
avatar: string
}
publishedAt: string
}
const { title, author, publishedAt } = Astro.props
---
<header class="mb-8">
<h1 class="text-4xl font-bold mb-4">{title}</h1>
<div class="flex items-center space-x-4 text-gray-600">
<img
src={author.avatar}
alt={author.name}
class="w-12 h-12 rounded-full"
/>
<div>
<p class="font-semibold">{author.name}</p>
<time>{new Date(publishedAt).toLocaleDateString()}</time>
</div>
</div>
</header>
// src/components/BlogContent.astro
import { marked } from 'marked'
interface Props {
content: string
}
const { content } = Astro.props
const htmlContent = marked(content)
---
<div class="prose lg:prose-xl max-w-none" set:html={htmlContent} />
Interactive Islands (Client Components)
// src/components/CommentSection.tsx
import { useState, useEffect } from 'react'
interface Comment {
id: string
content: string
author: string
createdAt: string
}
interface CommentSectionProps {
postId: string
}
export default function CommentSection({ postId }: CommentSectionProps) {
const [comments, setComments] = useState<Comment[]>([])
const [newComment, setNewComment] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
// Fetch comments when component hydrates
fetch(`/api/posts/${postId}/comments`)
.then(res => res.json())
.then(setComments)
}, [postId])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
try {
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newComment }),
})
if (response.ok) {
const comment = await response.json()
setComments(prev => [comment, ...prev])
setNewComment('')
}
} catch (error) {
console.error('Failed to post comment:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<section class="mt-12 pt-8 border-t">
<h2 class="text-2xl font-bold mb-6">Comments</h2>
<form onSubmit={handleSubmit} class="mb-8">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Write a comment..."
class="w-full p-3 border rounded-lg resize-none"
rows={3}
required
/>
<button
type="submit"
disabled={isSubmitting}
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Posting...' : 'Post Comment'}
</button>
</form>
<div class="space-y-4">
{comments.map((comment) => (
<div key={comment.id} class="border rounded-lg p-4">
<div class="flex justify-between items-start mb-2">
<span class="font-semibold">{comment.author}</span>
<time class="text-sm text-gray-500">
{new Date(comment.createdAt).toLocaleDateString()}
</time>
</div>
<p class="text-gray-700">{comment.content}</p>
</div>
))}
</div>
</section>
)
}
// src/components/ShareButtons.tsx
import { useState } from 'react'
interface ShareButtonsProps {
postUrl: string
postTitle: string
}
export default function ShareButtons({ postUrl, postTitle }: ShareButtonsProps) {
const [copied, setCopied] = useState(false)
const shareUrl = encodeURIComponent(postUrl)
const shareText = encodeURIComponent(postTitle)
const shareLinks = {
twitter: `https://twitter.com/intent/tweet?url=${shareUrl}&text=${shareText}`,
facebook: `https://www.facebook.com/sharer/sharer.php?u=${shareUrl}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}`,
}
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(postUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('Failed to copy:', error)
}
}
return (
<div class="flex items-center space-x-4 mt-8 pt-8 border-t">
<span class="text-sm font-medium text-gray-700">Share:</span>
<a
href={shareLinks.twitter}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-600 transition-colors"
>
Twitter
</a>
<a
href={shareLinks.facebook}
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 transition-colors"
>
Facebook
</a>
<a
href={shareLinks.linkedin}
target="_blank"
rel="noopener noreferrer"
class="text-blue-700 hover:text-blue-900 transition-colors"
>
LinkedIn
</a>
<button
onClick={copyToClipboard}
class="text-gray-600 hover:text-gray-800 transition-colors"
>
{copied ? 'Copied!' : 'Copy Link'}
</button>
</div>
)
}
E-commerce Product Page
// src/pages/products/[id].astro
import ProductGallery from '../../components/ProductGallery.astro'
import ProductInfo from '../../components/ProductInfo.astro'
import AddToCart from '../../components/AddToCart.tsx'
import ProductReviews from '../../components/ProductReviews.tsx'
import RelatedProducts from '../../components/RelatedProducts.astro'
export async function getStaticPaths() {
const products = await fetch('https://api.example.com/products').then(res => res.json())
return products.map((product: { id: string }) => ({
params: { id: product.id },
}))
}
const { id } = Astro.params
const product = await fetch(`https://api.example.com/products/${id}`).then(res => res.json())
const relatedProducts = await fetch(`https://api.example.com/products/${id}/related`).then(res => res.json())
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{product.name} - Our Store</title>
</head>
<body>
<main class="max-w-6xl mx-auto py-8 px-4">
<div class="grid md:grid-cols-2 gap-8">
<!-- Static content -->
<ProductGallery images={product.images} />
<div>
<ProductInfo
name={product.name}
price={product.price}
description={product.description}
specs={product.specifications}
/>
<!-- Interactive island -->
<AddToCart
client:load
productId={product.id}
price={product.price}
stock={product.stock}
/>
</div>
</div>
<!-- Interactive island -->
<ProductReviews client:visible productId={product.id} />
<!-- Static content -->
<RelatedProducts products={relatedProducts} />
</main>
</body>
</html>
Interactive Add to Cart Component
// src/components/AddToCart.tsx
import { useState } from 'react'
interface AddToCartProps {
productId: string
price: number
stock: number
}
export default function AddToCart({ productId, price, stock }: AddToCartProps) {
const [quantity, setQuantity] = useState(1)
const [isAdding, setIsAdding] = useState(false)
const [added, setAdded] = useState(false)
const handleAddToCart = async () => {
setIsAdding(true)
try {
const response = await fetch('/api/cart/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity }),
})
if (response.ok) {
setAdded(true)
setTimeout(() => setAdded(false), 3000)
}
} catch (error) {
console.error('Failed to add to cart:', error)
} finally {
setIsAdding(false)
}
}
return (
<div class="space-y-4">
<div class="flex items-center space-x-4">
<label class="text-sm font-medium">Quantity:</label>
<select
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
class="border rounded px-3 py-1"
>
{[...Array(Math.min(stock, 10))].map((_, i) => (
<option key={i + 1} value={i + 1}>
{i + 1}
</option>
))}
</select>
</div>
<div class="text-2xl font-bold text-green-600">
${(price * quantity).toFixed(2)}
</div>
<button
onClick={handleAddToCart}
disabled={isAdding || stock === 0}
class="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAdding ? 'Adding...' : added ? 'Added to Cart!' : stock > 0 ? 'Add to Cart' : 'Out of Stock'}
</button>
{stock > 0 && (
<p class="text-sm text-gray-600">
{stock} items in stock
</p>
)}
</div>
)
}
Hydration Strategies
Different Client Directives
// Different hydration strategies in Astro
import InteractiveComponent from '../components/InteractiveComponent.tsx'
import LazyComponent from '../components/LazyComponent.tsx'
import IdleComponent from '../components/IdleComponent.tsx'
import ResponsiveComponent from '../components/ResponsiveComponent.tsx'
import EventComponent from '../components/EventComponent.tsx'
---
<!-- Load immediately -->
<InteractiveComponent client:load />
<!-- Load when visible -->
<LazyComponent client:visible />
<!-- Load when idle -->
<IdleComponent client:idle />
<!-- Load on media query -->
<ResponsiveComponent client:media="(max-width: 768px)" />
<!-- Load on specific event -->
<EventComponent client:media="(hover: hover)" />
Performance Benefits
Loading Timeline
Bundle Size Comparison
Best Practices
1. Identify Interactive vs Static Content
// Static content (no JavaScript needed)
const staticContent = {
headers: true,
navigation: true,
mainContent: true,
footers: true,
}
// Interactive content (needs JavaScript)
const interactiveContent = {
forms: false,
sliders: false,
modals: false,
realTimeUpdates: false,
}
2. Choose Appropriate Hydration Strategies
// Immediate hydration for critical interactions
<LoginForm client:load />
// Lazy hydration for below-the-fold content
<CommentSection client:visible />
// Idle hydration for non-critical features
<NewsletterSignup client:idle />
3. Optimize Island Sizes
// Keep islands small and focused
// Good: Single responsibility
<AddToCartButton productId={id} />
// Avoid: Large, complex islands
<EntireProductPage product={product} />
Astro Configuration
Basic Configuration
// astro.config.mjs
import { defineConfig } from 'astro/config'
import react from '@astrojs/react'
import tailwind from '@astrojs/tailwind'
export default defineConfig({
integrations: [
react(), // Enable React islands
tailwind(), // Enable Tailwind CSS
],
build: {
inlineStylesheets: 'auto', // Optimize CSS delivery
},
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
// Split islands into separate chunks
'react-vendor': ['react', 'react-dom'],
},
},
},
},
},
})
Performance Monitoring
// Track island hydration performance
export function trackHydration(islandName: string) {
const startTime = performance.now()
return () => {
const duration = performance.now() - startTime
console.log(`${islandName} hydrated in ${duration.toFixed(2)}ms`)
}
}
// Usage in components
const trackHydration = trackHydration('CommentSection')
useEffect(() => {
trackHydration()
}, [])
Islands Architecture provides an excellent balance between performance and interactivity. By keeping most content static and only making necessary parts interactive, you get the best of both worlds: fast loading and rich user experience.