Skip to content
Codeloom
Astro

Astro Islands Architecture: Patterns and Anti-Patterns

Advanced island patterns in Astro: when to hydrate, how to share state between islands, mixing frameworks, lazy loading strategies, and mistakes that kill performance.

·8 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How to choose the right client directive for each component
  • Patterns for sharing state between isolated islands
  • How to mix React, Vue, Svelte, and Solid in one project
  • Lazy loading strategies that minimize JavaScript payload
  • Anti-patterns that silently destroy performance

Prerequisites

  • Astro basics
  • One frontend framework (React, Vue, or Svelte)

The Island Decision Framework

Every component in an Astro page starts as static HTML. The moment you add a client:* directive, it becomes an island: it gets a JavaScript bundle, a hydration strategy, and a runtime cost. The goal is to add directives only where interaction actually requires JavaScript, and to pick the directive that defers loading as long as possible.

The Client Directives

DirectiveWhen it loadsUse when
client:loadImmediately on page loadAbove-the-fold interactive UI (nav dropdowns, search bars)
client:idleAfter the page is idle (requestIdleCallback)Important but not immediately needed (comment forms, chat widgets)
client:visibleWhen the element scrolls into viewBelow-the-fold content (carousels, interactive charts)
client:mediaWhen a media query matchesMobile-only menus, responsive widgets
client:onlyClient only, no SSRComponents that cannot run on the server (canvas, WebGL, browser APIs)

Decision Flowchart

Ask these questions in order:

  1. Does this component need JavaScript at all? If it is purely visual, use no directive. Ship HTML and CSS only.
  2. Does it need to work immediately when the page loads? Use client:load.
  3. Does it need to work before the user scrolls to it? Use client:idle.
  4. Is it below the fold? Use client:visible.
  5. Does it depend on screen size? Use client:media.
  6. Does it crash on the server? Use client:only="react".

Sharing State Between Islands

Islands are isolated by default. Each one has its own component tree, its own state, and no shared context. This is a feature — it means one island cannot break another. But sometimes you need islands to communicate.

Pattern 1: Nano Stores

Nano Stores is a tiny (300 bytes) state management library that works across frameworks. Install it and any island can subscribe to shared state.

npm install nanostores @nanostores/react @nanostores/vue

Define a store:

// src/stores/cart.ts
import { atom, computed } from 'nanostores';

export type CartItem = { id: string; name: string; price: number; qty: number };

export const $cart = atom<CartItem[]>([]);

export const $cartTotal = computed($cart, (items) =>
  items.reduce((sum, item) => sum + item.price * item.qty, 0),
);

export function addToCart(item: Omit<CartItem, 'qty'>) {
  const current = $cart.get();
  const existing = current.find((i) => i.id === item.id);

  if (existing) {
    $cart.set(current.map((i) =>
      i.id === item.id ? { ...i, qty: i.qty + 1 } : i,
    ));
  } else {
    $cart.set([...current, { ...item, qty: 1 }]);
  }
}

export function removeFromCart(id: string) {
  $cart.set($cart.get().filter((i) => i.id !== id));
}

Use in a React island:

// src/components/AddToCartButton.tsx
import { useStore } from '@nanostores/react';
import { $cart, addToCart } from '@/stores/cart';

export default function AddToCartButton({ product }: { product: { id: string; name: string; price: number } }) {
  const cart = useStore($cart);
  const inCart = cart.some((item) => item.id === product.id);

  return (
    <button onClick={() => addToCart(product)} disabled={inCart}>
      {inCart ? 'In Cart' : 'Add to Cart'}
    </button>
  );
}

Use in a Vue island:

<!-- src/components/CartCounter.vue -->
<script setup>
import { useStore } from '@nanostores/vue';
import { $cart } from '@/stores/cart';

const cart = useStore($cart);
</script>

<template>
  <span class="cart-badge">{{ cart.length }}</span>
</template>

Both islands react to the same store. When the React button adds an item, the Vue counter updates instantly.

Pattern 2: Custom Events

For simpler communication, use the DOM:

// React island: dispatch event
function SearchInput() {
  const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    window.dispatchEvent(
      new CustomEvent('search:query', { detail: e.target.value }),
    );
  };

  return <input type="search" onChange={handleInput} placeholder="Search..." />;
}
// React island: listen for event
import { useEffect, useState } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');

  useEffect(() => {
    const handler = (e: CustomEvent) => setQuery(e.detail);
    window.addEventListener('search:query', handler as EventListener);
    return () => window.removeEventListener('search:query', handler as EventListener);
  }, []);

  return <div>Searching for: {query}</div>;
}

Pattern 3: URL State

For state that should survive navigation and be shareable, put it in the URL:

function FilterPanel() {
  const updateFilter = (category: string) => {
    const url = new URL(window.location.href);
    url.searchParams.set('category', category);
    window.history.pushState({}, '', url);
    window.dispatchEvent(new Event('popstate'));
  };

  return (
    <div>
      <button onClick={() => updateFilter('all')}>All</button>
      <button onClick={() => updateFilter('frontend')}>Frontend</button>
      <button onClick={() => updateFilter('backend')}>Backend</button>
    </div>
  );
}

Mixing Frameworks

Astro supports React, Vue, Svelte, Solid, Preact, and Lit simultaneously. Each framework has its own integration and its own island runtime.

---
// src/pages/index.astro
import ReactCounter from '@/components/ReactCounter';
import VueCounter from '@/components/VueCounter.vue';
import SvelteCounter from '@/components/SvelteCounter.svelte';
---

<h1>Framework Buffet</h1>

<ReactCounter client:visible />
<VueCounter client:visible />
<SvelteCounter client:visible />

Each component ships only its own framework runtime. The React counter ships React, the Vue counter ships Vue. They do not share a runtime.

When to Mix

  • Migration: Moving from React to Svelte? Convert components one at a time. Old React components keep working alongside new Svelte ones.
  • Best tool for the job: Use Solid for a performance-critical data table, React for a complex form with good library support, and Svelte for a simple toggle.
  • Team preferences: Different team members can use what they know.

When Not to Mix

  • Small projects: The overhead of multiple framework runtimes is not worth it unless you have a specific reason.
  • Shared component libraries: If your design system is in React, converting a few components to Vue just creates maintenance burden.

Lazy Loading Strategies

Progressive Island Loading

Order your islands by priority:

---
import Nav from '@/components/Nav';
import SearchBar from '@/components/SearchBar';
import CommentSection from '@/components/CommentSection';
import RelatedPosts from '@/components/RelatedPosts';
import Newsletter from '@/components/Newsletter';
---

<!-- Critical: loads immediately -->
<Nav client:load />

<!-- Important: loads when browser is idle -->
<SearchBar client:idle />

<main>
  <article><slot /></article>

  <!-- Below fold: loads when visible -->
  <CommentSection client:visible />
  <RelatedPosts client:visible />

  <!-- Low priority: loads when visible -->
  <Newsletter client:visible />
</main>

Conditional Islands

Only hydrate if the user’s device can handle it:

---
import HeavyVisualization from '@/components/HeavyVisualization';
---

<!-- Only hydrate on screens wider than 768px -->
<HeavyVisualization client:media="(min-width: 768px)" />

On mobile, the component renders as static HTML with no JavaScript. On desktop, it hydrates and becomes interactive.

Anti-Patterns

1. The “client:load Everything” Pattern

<!-- BAD: every component loads JS immediately -->
<Header client:load />
<Hero client:load />
<Features client:load />
<Testimonials client:load />
<Footer client:load />

This is equivalent to a SPA. You lose every performance benefit of Astro. Instead, ask which of these actually need JavaScript. A <Header> with a dropdown menu might need client:load. A <Features> section that is purely visual needs no directive at all.

2. The Wrapper Island

<!-- BAD: wrapping static content in a React component just for styling -->
<StyledSection client:load>
  <h2>Our Team</h2>
  <p>We build great things.</p>
</StyledSection>

If StyledSection is just a div with CSS, use an Astro component. No JavaScript needed.

3. Islands That Are Too Big

<!-- BAD: the entire page is one island -->
<EntirePage client:load />

Break it down. Extract the interactive parts as small islands and leave the rest as static Astro components.

4. Prop Drilling Through Islands

Islands cannot share React context or Vue provide/inject across island boundaries. If you pass complex objects through nested islands, use Nano Stores instead.

Measuring Island Impact

Use the Astro Dev Toolbar (built into astro dev) to see which islands are on the page, how much JavaScript each one ships, and when they hydrate. In production, check the Network tab for the actual bundle sizes.

# Check total JS shipped per page
npx astro build
ls -la dist/_astro/*.js | awk '{total += $5} END {print total/1024 "KB total JS"}'

A well-architected Astro site with islands ships 10-50KB of JavaScript for a typical page. If you are shipping 200KB+, you probably have too many client:load directives or an island that is too large.

Summary

The island architecture is about saying no to JavaScript by default and yes only where interaction demands it. Choose the most deferring client directive possible. Share state between islands with Nano Stores or custom events rather than wrapping everything in a framework provider. Mix frameworks only when it solves a real problem. And measure your JavaScript budget to make sure islands stay islands, not continents.