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.