Email copiado — support@tuurt.com
Cargando experiencia
astro · October 10, 2026 · 6 min

Islands Architecture in Astro: Deciding What Actually Needs Client-Side JavaScript

Astro hydrates nothing by default, but it does not decide an island boundary for us either. We look at the case where hydrating a whole product card instead of one button bloated an entire catalog bundle.

By Tuurt Team

Islands Architecture in Astro: Deciding What Actually Needs Client-Side JavaScript

Astro starts from a premise that's unusual among modern frameworks: by default, a page ships no JavaScript to the browser at all. Everything renders to static HTML on the server or at build time, and JavaScript only enters the picture when a component explicitly asks for it through a client:* directive. Astro calls that component, hydrated on the client in isolation from the rest of the page, an island.

The idea is solid and solves a real problem: most of a page -header, copy, images, navigation- needs no interactivity at all, and shipping the full React or Vue runtime to render content that could just be plain HTML is wasted work. The problem isn't the idea. It's that Astro doesn't decide for us which part of a component actually needs that hydration. That call is ours to make, component by component, and getting it wrong costs real bundle size.

What Astro decides on its own, and what it doesn't

Astro does solve one thing on its own: a .astro component with no client:* directive renders to HTML on the server, full stop, no exceptions and no extra configuration. In most of the projects we've migrated to Astro, that covers half or more of a page's components: content cards, text sections, footers, breadcrumbs.

What Astro doesn't solve is the granularity of that component. If we write a ProductCard.tsx that bundles the product image, price, description, and an "add to cart" button, and we mark it client:load because the button needs to handle a click, Astro hydrates the whole component -image, price, and description included- even though none of those three pieces will ever change or respond to anything on the client. The unit of hydration is the component that receives the directive, not the interactive sliver inside it. That mix-up -treating an island as "the section of the page that has something interactive" instead of "the smallest component that actually needs JavaScript"- is the most common cause of bloated bundles we run into when reviewing Astro projects.

The directives aren't interchangeable

---
import ProductCard from '../components/ProductCard.tsx';
import AddToCartButton from '../components/AddToCartButton.tsx';
---
<ProductCard product={product} client:load />

Before we even get to what to hydrate, there's the question of when. Astro offers several directives, and each one solves a different problem:

  • client:load hydrates as soon as the page finishes loading. It's the right call for something visible immediately and critical to the first interaction, but it's also the one people reach for by default without thinking, when it's rarely the option that costs the least blocking JavaScript.
  • client:idle waits for the main thread to free up (it uses requestIdleCallback, with a setTimeout-based fallback where that's not available). It makes sense for interactivity that doesn't need to be ready in the first second: an accordion, a newsletter form in the footer.
  • client:visible waits for the component to enter the viewport, using IntersectionObserver. It's the best-performing option for anything below the fold, because the JavaScript isn't even downloaded until it's needed.
  • client:media hydrates only when a media query matches, useful for interactivity that only exists on mobile or only on desktop, like a hamburger menu.
  • client:only skips server rendering entirely. It's necessary for components that depend on browser APIs that don't exist in the build environment, but it has a clear cost: there's no initial HTML, so the user sees a blank gap until the JavaScript arrives and renders.

Reaching for client:load everywhere out of habit cancels out much of the benefit of islands architecture: we're still blocking the main thread with early hydration for things that could have waited.

The case: hydrating the whole card instead of the button

In a catalog with around forty product cards per page, every ProductCard carried client:load because the "add to cart" button needed an onClick. Once we measured it, the result was predictable: forty full instances of the component hydrating on the client, each one dragging along price-formatting logic, image-selection logic based on stock availability, and product-variant handling -none of which responded to any user event, but all of which shipped as part of the island's bundle and ran on the client anyway when the component mounted.

The fix wasn't switching the directive, it was redrawing the component's boundary. We pulled the button out into its own component:

// AddToCartButton.tsx
export default function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    await addToCart(productId);
    setLoading(false);
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Adding...' : 'Add to cart'}
    </button>
  );
}

And we left the card as a static .astro component that renders that button as its only island, with client:visible since there's no reason to hydrate a button that hasn't even scrolled into view yet:

---
import AddToCartButton from './AddToCartButton.tsx';
---
<article class="product-card">
  <img src={product.image} alt={product.name} />
  <h3>{product.name}</h3>
  <p>{formatPrice(product.price)}</p>
  <AddToCartButton productId={product.id} client:visible />
</article>

The card's HTML -image, name, price- is now plain static markup generated at build time, with no framework runtime attached to it. The only thing that hydrates is the button, and only once the card enters the viewport. Price formatting, which used to live inside the React component and got recalculated on the client every time it mounted, became a utility function that runs on the server while building the HTML, since there was never a reason for that calculation to happen in the browser too.

The cost the bundle size doesn't show: serialized props

There's a second cost that tends to go unnoticed even after splitting islands correctly: every prop we pass to a hydrated component gets serialized to JSON and embedded in the page's HTML, so the client can rebuild the component's initial state without fetching it again from the server. If the island receives the entire product object -with its array of images at different resolutions, its variant history, its SEO metadata- instead of just the productId it actually needs, that whole object travels embedded in the HTML, even if the hydrated component only touches one field. Passing the identifier and letting the component fetch what's missing, or passing only the fields the interactive part actually uses, keeps the savings from hydrating fewer components from quietly leaking back out through bloated props.

What we check before marking a component as an island

Before attaching a client:* directive to a component, we first separate its interactive part from its purely presentational part, even if that means creating a new component just for that; we pick the directive based on when the interaction actually needs to be ready -client:visible by default for anything below the fold, client:load reserved for what truly matters in the first second-; we review which props we're passing to that island and trim them down to the fields the interactive part uses, instead of forwarding the full object the page already had; and we measure, with the network inspector, how much JavaScript gets downloaded per page type before calling the migration done, because islands architecture only cuts down JavaScript if each island's boundary is drawn correctly, not simply because the project uses Astro.

astro frontend performance javascript
← Back to blog