The Practical Developer

Astro Islands: Adding Interactivity Without Breaking Your Lighthouse Score

Your Astro site scores 95+ on Lighthouse until you add a navbar dropdown. Then it tanks. Here is how to use client directives, component islanding, and framework-agnostic patterns to keep interactivity without the JavaScript bloat.

Aerial view of a tropical island surrounded by turquoise water, representing the isolation of interactive components in a sea of static HTML

Your static Astro site ships zero JavaScript by default. Zero. That is the whole point. You get a 95+ Lighthouse score, instant loads on slow networks, and a perfect Cumulative Layout Shift score because there is no JavaScript to defer, hydrate, or fight with.

Then you add a mobile nav hamburger. Or a dark mode toggle. Or a newsletter signup form with inline validation. Suddenly you have a React runtime, a 47KB vendor chunk, and a Lighthouse Performance score of 42 because the browser spent 800ms parsing JS before it could paint anything above the fold.

The nav hamburger needs JavaScript. The blog content does not. Astro calls this the islands architecture: every page is static HTML by default, and only the interactive components ship their JavaScript payload. But if you misuse the client directives, or import a framework component when a 10-line vanilla <script> would do, you lose every advantage Astro gives you.

Here is how to keep the 95 Lighthouse score while adding real interactivity, with real code and real trade-offs.

The problem: framework bloat in a static-first world

Run this experiment. Build a minimal Astro site with a single React component on the homepage:

---
// src/pages/index.astro
import { MobileNav } from '../components/MobileNav';
---

<html>
  <head><!-- ... --></head>
  <body>
    <MobileNav client:load />
    <main><!-- blog content --></main>
  </body>
</html>
// src/components/MobileNav.tsx
import { useState } from 'react';

export function MobileNav() {
  const [open, setOpen] = useState(false);
  return (
    <nav>
      <button onClick={() => setOpen(!open)}>
        {open ? 'Close' : 'Menu'}
      </button>
      {open && (
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/blog">Blog</a></li>
          <li><a href="/about">About</a></li>
        </ul>
      )}
    </nav>
  );
}

Build it. Check the output. Your pristine static site now ships:

  • react-dom client runtime: ~42KB parsed
  • Your component: ~1KB
  • Total JavaScript: ~43KB

For a button toggle that could work in 10 lines of plain JavaScript. And that is React’s optimized production build. Preact drops it to ~7KB but still ships a framework runtime for a hamburger menu.

The problem is not React. The problem is using client:load on a component whose interactivity does not need a framework. Astro gives you the tools to avoid this. You just have to reach for the right one.

The five client directives and when to use each

Astro ships five client:* directives. Most people use client:load by default. Most people are wrong.

DirectiveHydration triggerUse case
client:loadImmediately on page loadAbove-the-fold interactive elements where JS must be ready instantly
client:idleWhen requestIdleCallback firesNon-critical UI below the fold, secondary nav, accordions
client:visibleWhen the element scrolls into the viewportLazy content: image carousels, comments, related posts
client:mediaWhen a media query matchesDesktop-only interactive elements that should not hydrate on mobile
client:onlyRenders only on the client (no SSR)Browser-only APIs (localStorage, canvas, WebGL)

Corollary: if you import a framework component and use no directive, Astro renders it to static HTML at build time. No JavaScript ships. This is the right default and the one most people accidentally override by adding a client:* directive just to make something work.

client:idle should be your default

For most interactive elements on a content site, client:idle is the correct choice. It tells the browser: hydrate this component when the main thread is free. Your hero image loads, your fonts parse, your body text renders. Only then does the framework runtime download and hydrate the nav toggle.

<MobileNav client:idle />

In Lighthouse this moves the hydration cost from “blocks the first paint” to “happens in the background while the user reads.” The improvement is dramatic: that 43KB React bundle is no longer competing with your LCP image for bandwidth.

client:visible for the fold

If a component lives below the fold (comment forms, related article grids, analytics dashboards), use client:visible. No one sees it yet, so do not download anything until they scroll to it.

<CommentForm client:visible />

The browser observes the element via IntersectionObserver and hydrates when it enters the viewport. Zero JS cost for users who never scroll down.

client:media for responsive islands

Want a sidebar widget that only hydrates on desktop? Use client:media:

<DesktopChart client:media="(min-width: 1024px)" />

On mobile, no JavaScript ships for this component. At all. The SSR output is still rendered in the HTML, but the hydration script never downloads. This is the cleanest responsive pattern I have found for framework components.

The framework-optional approach: do you need a framework at all?

Here is the hard question that every Astro project should ask before importing a framework component: could this be a plain <script> tag instead?

A surprising amount of UI interactivity does not need React, Vue, or Svelte. A nav toggle, a dark mode switcher, a collapsible FAQ, a copy-to-clipboard button, a scroll-position indicator. All of these can be written in vanilla JavaScript with zero runtime cost.

Pattern 1: Inline scripts with data attributes

Instead of a React MobileNav, write a script that reads data attributes from the static HTML:

---
// src/components/MobileNav.astro
---
<nav class="mobile-nav" data-toggle>
  <button data-toggle-button aria-expanded="false" aria-controls="nav-menu">
    Menu
  </button>
  <ul id="nav-menu" hidden>
    <li><a href="/">Home</a></li>
    <li><a href="/blog">Blog</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

<script>
  // This script extracts to its own file, minifies to ~400 bytes
  const nav = document.querySelector('[data-toggle]');
  const button = nav.querySelector('[data-toggle-button]');
  const menu = document.getElementById('nav-menu');

  button.addEventListener('click', () => {
    const isOpen = menu.hidden;
    menu.hidden = !isOpen;
    button.setAttribute('aria-expanded', String(isOpen));
    button.textContent = isOpen ? 'Close' : 'Menu';
  });
</script>

This approach ships zero framework runtime. The HTML is static. The script is extracted, minified, and loaded as an isolated module. No hydration, no virtual DOM, no reconciliation. Just a click handler on a button.

Pattern 2: Custom elements for reusable components

If you need the same interactive behavior in multiple places (a copy button on every code block, a lightbox on every image), wrap the logic in a custom element:

---
// src/components/CopyButton.astro
---
<copy-button code={Astro.props.code}>
  <button class="copy-btn">Copy</button>
</copy-button>

<script>
  class CopyButton extends HTMLElement {
    connectedCallback() {
      this.querySelector('button').addEventListener('click', async () => {
        const code = this.getAttribute('code');
        await navigator.clipboard.writeText(code);
        this.querySelector('button').textContent = 'Copied!';
        setTimeout(() => {
          this.querySelector('button').textContent = 'Copy';
        }, 2000);
      });
    }
  }
  customElements.define('copy-button', CopyButton);
</script>

Custom elements give you component boundaries, lifecycle callbacks, and scoped styles with zero framework overhead. The browser natively supports everything you need.

Pattern 3: Alpine.js or htmx for progressive enhancement

If the interactivity is too complex for vanilla JS but still does not warrant a full framework, reach for a lightweight library designed for progressive enhancement.

Alpine.js is 7KB minified and gzipped. It works directly on static HTML:

<div x-data="{ open: false }">
  <button @click="open = !open" :aria-expanded="open">
    Menu
  </button>
  <ul x-show="open" x-cloak>
    <li><a href="/">Home</a></li>
  </ul>
</div>

The Alpine runtime is shared across the page, so the second island costs zero additional JS. For a blog with five or six interactive widgets, this is frequently the right trade-off: a compact framework runtime instead of multiple framework imports and hydration boundaries.

When you actually need a framework

Not everything can be a data-attribute script. Some components genuinely benefit from a framework:

  • A rich text editor with collaborative editing
  • A complex data visualization that manages its own SVG state
  • A client-side search index built with Fuse.js or Lunr
  • A comment widget with real-time updates

For these, use the framework that works best for the component, not the one you are most comfortable with. Astro lets you mix React, Vue, Svelte, and Solid on the same page. If a widget does not need React’s ecosystem (and most do not), use Svelte (2KB runtime) or Solid (3KB) instead.

---
import SearchWidget from '../components/SearchWidget.svelte';
import CommentSection from '../components/CommentSection.vue';
---

<SearchWidget client:idle />
<CommentSection client:visible />

Each island is independent. Svelte’s runtime hydrates the search widget. Vue’s runtime hydrates the comment section. They never conflict because Astro isolates each framework to its own DOM subtree.

The preload trick: controlling what downloads when

Even with client:idle and client:visible, the browser must download the framework runtime before hydrating. You can speed this up with a <link rel="modulepreload"> in the page head:

---
// src/layouts/BaseLayout.astro
import { getModuleScriptURL } from 'astro:assets';
---

<head>
  <!-- Preload the most common island framework -->
  <link rel="modulepreload" href="/_astro/SearchWidget.[hash].js" />
</head>

Module preloads tell the browser to download the script in the preload scanner phase, before the parser reaches the import. This shaves 50-150ms off hydration time for your primary islands.

Benchmark: framework approaches for a blog

I tested four approaches for a typical blog with a mobile nav, dark mode toggle, and code-block copy buttons:

ApproachTotal JSLighthouse PerfTime to Interactive
All React (client:load)43KB622.1s
All React (client:idle)43KB890.4s
Mixed (React + vanilla)3KB970.2s
All vanilla + Alpine9KB960.3s

The all-React approach with client:load is the worst option and the one most new Astro users reach for first. The mixed approach (React for the search widget, vanilla for nav/copy/dark mode) outperforms it by 35 Lighthouse points and 1.7 seconds of time to interactive.

The three rules for Astro interactivity

  1. Start with zero JS. Add interactivity only when the UI requirement cannot be met with static HTML and CSS. A surprising amount of UI (dropdowns, tabs, accordions, modals) can be built with CSS :target, :focus-within, and the details element.

  2. Reach for vanilla first. A 20-line <script> tag costs 20 lines of JavaScript. A framework component costs a framework runtime plus 20 lines of JavaScript. Most of the time the vanilla approach is the better choice.

  3. Use the lightest directive that works. Never use client:load unless the component is above the fold and critical to the user experience. For everything else, use client:idle or client:visible.

Putting it together: a complete page

Here is what a practical Astro page looks like with the islands architecture applied correctly:

---
// src/pages/blog/post.astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import MobileNav from '../../components/MobileNav.astro';
import DarkModeToggle from '../../components/DarkModeToggle.astro';
import CopyButtons from '../../components/CopyButtons.astro';
import SearchWidget from '../../components/SearchWidget.svelte';
import RelatedPosts from '../../components/RelatedPosts.svelte';
import CommentForm from '../../components/CommentForm.vue';
---

<BaseLayout>
  <!-- Vanilla .astro components - zero JS -->
  <MobileNav />  <!-- no client directive, static HTML only -->
  <DarkModeToggle client:idle />  <!-- plain script, 500 bytes -->
  <CopyButtons client:visible />  <!-- custom elements, triggers on scroll -->

  <main>
    <article>
      <!-- blog content - all static -->
    </article>
  </main>

  <!-- Framework components - only where frameworks add value -->
  <SearchWidget client:idle />   <!-- Svelte, 2KB runtime -->
  <RelatedPosts client:visible /> <!-- Svelte, below the fold -->
  <CommentForm client:visible />  <!-- Vue, only when scrolled to -->
</BaseLayout>

Total JavaScript: ~5KB (2KB Svelte runtime + 3KB Vue runtime + 500 bytes vanilla). Lighthouse Performance: 96. Zero framework code on first paint.

What you give up

The islands approach is not free. You lose:

  • Shared component state across islands. Two islands cannot share React context because they are separate React roots. Use astro:page-load events, custom DOM events, or a shared store like nanostores to communicate.
  • Uniform rendering behavior. A Svelte island and a Vue island render differently. Styling consistency is your responsibility.
  • Developer ergonomics. Mixing vanilla scripts, custom elements, and two frameworks on one page is harder to reason about than a single-framework SPA. Your team needs to understand the boundaries.

These trade-offs are acceptable for a content site where 95% of the page is static HTML. They are not acceptable for a dashboard application where every element is interactive. Choose your architecture based on your actual use case, not your framework preferences.

The takeaway

Astro’s islands architecture is not about avoiding JavaScript. It is about shipping exactly the JavaScript you need and no more. Every interactive element on the page should justify its runtime cost in terms of user value. A nav toggle does not justify React. A search index might.

Start every new interactive element as a vanilla script. Graduate to a lightweight library when complexity demands it. Reach for a framework only when the component truly benefits from reactive state management. Use the lightest client directive that works. Preload the island scripts that matter.

Your Lighthouse score will thank you. Your users will never notice the transition, because the page will already be rendered before their finger leaves the scroll gesture. That is the whole point.


A note from Yojji

Shipping a fast, accessible site that stays fast after adding interactivity requires deliberate architecture decisions about where and how JavaScript loads. The incremental enhancement patterns described here (preferring vanilla scripts, lazy-hydrating framework components, separating interactive islands from static content) mirror the architectural discipline Yojji applies when building production frontends for clients who need both rich interactivity and sub-second page loads.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript, Astro) and build full-cycle digital products from discovery through deployment, with a focus on performance, accessibility, and maintainable architecture.