DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Astro + React 19 Islands: Shipping Zero JavaScript Until User Interaction—The CitizenApp Case Study

Astro + React 19 Islands: Shipping Zero JavaScript Until User Interaction—The CitizenApp Case Study

Most teams treat Astro and React as opposing forces: Astro for static marketing, React for interactive dashboards. At CitizenApp, we stopped thinking binary. We realized our users hit the landing page, pricing table, and docs once—then lived in the dashboard. Shipping 150KB of React JavaScript to render a static hero section was insane.

Here's what I learned forcing Astro and React 19 to coexist: you can render 80% of your site as pure HTML and hydrate islands only when users interact with them. On CitizenApp, this cut initial page weight from 380KB to 145KB—a 62% reduction—without sacrificing the interactivity power users demanded in the app.

This isn't "use Astro for marketing and React for the app." It's architect your entire product as islands, ship nothing until needed, and trust React to handle the heavy lifting when it matters.

The Problem: React's Hydration Tax

I spent the first six months of CitizenApp shipping a monolithic React app. Every page—even the docs—loaded React, ReactDOM, our UI component library, and routing logic. A user visiting the pricing page paid 280KB of JavaScript before they could click anything.

React's hydration is powerful but expensive. The framework must:

  1. Parse and evaluate the JavaScript bundle
  2. Serialize and hydrate component state
  3. Attach event listeners to DOM nodes
  4. Validate that the server-rendered HTML matches the client bundle

For a static pricing table? Insane.

I read about Astro's island architecture and realized: we don't need a full app framework for most pages. We need islands—isolated React components that hydrate on demand.

Astro Island Architecture: The Why

Astro's brilliance is radical: render everything as static HTML by default. When you need interactivity, drop in a React component and tell Astro when to hydrate it.

---
// src/pages/pricing.astro
import PricingTable from '../components/PricingTable.tsx';
---

<html>
  <body>
    <h1>CitizenApp Pricing</h1>
    <p>Our static hero section renders as pure HTML—zero JavaScript shipped.</p>

    {/* This React component hydrates only on user interaction */}
    <PricingTable client:idle />
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The client:idle directive tells Astro: "Render this as HTML on the server. When the browser is idle (after the initial paint), load the React component's JavaScript and hydrate it."

This is the key insight: you're not choosing between Astro and React. You're choosing when React hydrates.

Three Hydration Strategies We Use

1. client:idle — Marketing and Docs Pages

For pages users read but don't heavily interact with, hydrate when the browser has spare cycles.

---
// src/pages/docs.astro
import SearchBox from '../components/SearchBox.tsx';
import CodeExample from '../components/CodeExample.tsx';
---

<h2>API Documentation</h2>
<p>All static content renders instantly.</p>

{/* Hydrate the search box after 2 seconds of idle time */}
<SearchBox client:idle />

{/* Examples stay static unless the user clicks them */}
<CodeExample client:idle language="python" />
Enter fullscreen mode Exit fullscreen mode

Why client:idle? Users land on docs to read. If they search, they'll wait 100ms for hydration. That's fine. The 98% of users who just skim get instant paint.

2. client:visible — Below-the-Fold Interactivity

For components that only need hydration when visible:

---
// src/pages/index.astro
import DashboardPreview from '../components/DashboardPreview.tsx';
import PricingCalculator from '../components/PricingCalculator.tsx';
---

<section>
  <h1>CitizenApp: AI for Governance</h1>
  <p>Hero section is pure HTML. Instant paint.</p>
</section>

{/* 
  This calculator is below the fold. 
  Only hydrate if the user scrolls to it.
*/}
<PricingCalculator client:visible />
Enter fullscreen mode Exit fullscreen mode

I use client:visible aggressively on landing pages. 60% of visitors bounce before scrolling. Why ship JavaScript for components they never see?

3. client:load — Authenticated Dashboard

For the app itself, hydrate immediately. These users need interactivity.

---
// src/pages/dashboard.astro
import Dashboard from '../components/Dashboard.tsx';
import ProtectedRoute from '../components/ProtectedRoute.tsx';
---

{/* 
  Inside the authenticated app, users expect React's snappiness.
  Hydrate immediately.
*/}
<ProtectedRoute client:load>
  <Dashboard />
</ProtectedRoute>
Enter fullscreen mode Exit fullscreen mode

Real CitizenApp Setup: Hybrid Monorepo

Here's how we structure it:

citizenapp/
├── src/
│   ├── pages/
│   │   ├── index.astro          # Landing (Astro)
│   │   ├── pricing.astro         # Pricing (Astro + islands)
│   │   ├── dashboard.astro       # App (React)
│   │   └── api/                  # FastAPI routes proxied
│   ├── components/
│   │   ├── PricingTable.tsx       # React island
│   │   ├── Dashboard.tsx          # React island (full app)
│   │   ├── SearchBox.tsx          # React island
│   │   └── StaticHeader.astro     # Pure Astro
│   └── layouts/
│       └── AppLayout.astro
├── astro.config.mjs
└── tailwind.config.mjs
Enter fullscreen mode Exit fullscreen mode
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';

export default defineConfig({
  integrations: [react(), tailwind()],
  output: 'hybrid', // Key: allows both SSG and SSR per-route
  adapter: 'vercel', // or Cloudflare Pages
});
Enter fullscreen mode Exit fullscreen mode

The output: 'hybrid' setting is critical. It lets you:

  • Pre-render static pages at build time (instant CDN serving)
  • SSR authenticated routes on-demand
  • Mix both in the same project

The Gotcha: State Management Across Islands

Here's where I got burned.

I had a UserPreferences island and a DashboardContent island. When the user toggled dark mode in UserPreferences, DashboardContent didn't update because they're isolated React trees.

// This WON'T work — islands are separate React instances
const UserPreferences = () => {
  const [darkMode, setDarkMode] = useState(false);
  return <button onClick={() => setDarkMode(!darkMode)}>Toggle</button>;
};

const DashboardContent = () => {
  // Doesn't know about darkMode from UserPreferences
  return <div>Content</div>;
};
Enter fullscreen mode Exit fullscreen mode

The fix: lift state to Astro using view transitions and HTTP headers, or use a shared store (localStorage + custom events).

// Better: use localStorage + event listeners
const UserPreferences = () => {
  const [darkMode, setDarkMode] = useState(
    localStorage.getItem('darkMode') === 'true'
  );

  const toggle = () => {
    const newMode = !darkMode;
    setDarkMode(newMode);
    localStorage.setItem('darkMode', String(newMode));
    // Broadcast to other islands
    window.dispatchEvent(
      new CustomEvent('darkModeChanged', { detail: { darkMode: newMode } })
    );
  };

  return <button onClick={toggle}>Toggle</button>;
};

const DashboardContent = () => {
  const [darkMode, setDarkMode] = useState(
    localStorage.getItem('darkMode') === 'true'
  );

  useEffect(() => {
    const handleChange = (e: CustomEvent) => {
      setDarkMode(e.detail.darkMode);
    };
    window.addEventListener('darkModeChanged', handleChange as EventListener);
    return () => window.removeEventListener('darkModeChanged', handleChange as EventListener);
  }, []);

  return <div className={darkMode ? 'dark' : ''}>{/* ... */}</div>;
};
Enter fullscreen mode Exit fullscreen mode

It's not elegant, but it works. For CitizenApp's dashboard, we moved to a single large React island rather than multiple smaller ones—simpler state, better UX.

Metrics That Matter

On CitizenApp:

Metric Before (React SPA) After (Astro Islands) Improvement
Initial JS Bundle 280KB 65KB 77% ↓
First Contentful Paint 2.8s 0.9s 68% ↓
Time to Interactive (landing) 4.2s 1.1s 74% ↓
Time to Interactive (dashboard) 2.1s 2.3s ✓ Acceptable

The landing page went from a 4.2s interactive experience to 1.1s. The dashboard—where users live—is essentially unchanged because it hydrates immediately anyway.

When NOT to Use Islands

Don't over-engineer this:

  • Single-page apps with heavy interactivity: Use React. Islands add complexity.
  • Real-time collaborative features: Islands are isolated; use a monolithic React app.
  • Complex state dependencies: If your components talk constantly, it's one app, not islands.

CitizenApp uses Astro islands for the marketing funnel and public-facing docs. The authenticated dashboard is a single, large React island. That's it.

Takeaway

Astro + React 19 isn't about choosing a framework. It's about **architectural clarity: render

Top comments (0)