Confession: I spent about three months last summer telling myself that my marketing site’s 340 KB React bundle was fine. It wasn’t fine. The page had a hero, a pricing table, and one dropdown menu, and it was shipping enough JavaScript to run a small SaaS dashboard. I was hydrating a completely static header on a page whose only interactive element was a mobile menu that half the visitors never opened.
I finally rewrote it in Astro over a weekend. The bundle dropped to 29 KB. Lighthouse went from a yellow 71 to a green 98, and nobody complained about the loss of the shimmering React overhead. What convinced me was Astro islands, specifically the idea that hydration isn’t a page-level decision. It’s a component-level one.
This post is what I actually ship now, six months in. Not the marketing pitch. The parts I still reach for, the parts I got wrong, and the couple of places I still keep Next.js around.
What an island actually is (in code, not marketing)
Astro’s default is to render everything on the server, ship pure HTML, and send zero JavaScript to the browser. An island is a component you explicitly opt into hydrating. That’s it. The rest of the page stays as static markup and never boots a framework runtime.
Here’s the pattern I use most. My page is a .astro file (HTML plus a bit of frontmatter for the server-side bits), and I import React components only where I actually need interactivity:
---
// src/pages/pricing.astro
import Layout from '../layouts/Layout.astro';
import PricingTable from '../components/PricingTable.astro';
import CurrencyToggle from '../components/CurrencyToggle.tsx';
---
<Layout title="Pricing">
<h1>Pick a plan</h1>
<CurrencyToggle client:idle />
<PricingTable />
</Layout>
PricingTable.astro renders on the server and ships as HTML. CurrencyToggle.tsx is a React component, but it only gets hydrated because I added client:idle. Without a client:* directive, the React would render server-side and then ship as HTML with no runtime.
Compare this to the equivalent in Next.js App Router, where any client component in the tree drags along the React runtime whether you like it or not:
// app/pricing/page.tsx
'use client';
import { useState } from 'react';
import PricingTable from './PricingTable';
import CurrencyToggle from './CurrencyToggle';
export default function Page() {
const [currency, setCurrency] = useState('USD');
return (
<>
<h1>Pick a plan</h1>
<CurrencyToggle value={currency} onChange={setCurrency} />
<PricingTable currency={currency} />
</>
);
}
You can split this into server and client components in Next, and I do, but the boundary is easier to forget. Astro forces the boundary to be explicit, one component at a time. The official islands documentation has a solid explanation of how the compiler handles this if you want the deeper dive.
When I reach for client:idle vs client:visible vs client:only
The client directives sound like they’d overlap. They don’t. My rough rules after six months:
client:load hydrates immediately when the page loads. I use it almost nowhere because it defeats the point of islands. If a component needs to be interactive before the browser is idle, I usually rewrite it to not need to be.
client:idle hydrates during the browser’s requestIdleCallback. This is my default for anything above the fold that isn’t critical for first paint. A currency toggle. A login button that opens a modal on click. A dropdown that shows when you tap the avatar.
client:visible hydrates when the component scrolls into view, using IntersectionObserver under the hood. My default for anything below the fold: comment sections, related-post carousels, testimonial sliders that nobody scrolls to on a mobile viewport.
client:only="react" skips server rendering entirely and renders only on the client. I reach for this when a component depends on window or a client-side store like localStorage that would throw during SSR.
The one I got wrong at first was client:load. I was using it out of caution, and it was quietly killing my TTI numbers. Switching those to client:idle was a five-minute change that shaved 200 ms off my interactive time on a mid-tier Android device. That’s the kind of change I’d have hated to make in a Next.js codebase, where hydration is more or less all-or-nothing per route.
Server islands: the async chunk I didn’t know I wanted
Server islands landed properly in Astro 5 and they’re the feature that convinced me to migrate the rest of the site, not just the marketing pages. The idea is that parts of a page needing fresh data (a user’s cart badge, a personalized greeting) can render as separate async chunks after the main HTML ships.
The syntax is a single prop:
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Hero from '../components/Hero.astro';
import CartBadge from '../components/CartBadge.astro';
---
<Layout title="Home">
<Hero />
<CartBadge server:defer>
<span slot="fallback">Cart</span>
</CartBadge>
</Layout>
The static parts stream instantly. The CartBadge renders on the server in a second pass, with a fallback shown until the deferred HTML arrives. No client-side fetch, no useEffect, no loading spinner I had to hand-write. The cart count comes from the same database call it always did, but it doesn’t block the initial paint.
This is what Next.js Partial Prerendering promises, and Astro’s version is simpler to reason about. There’s no unstable_ prefix and no debate about which components can and can’t be dynamic. If Astro server islands sound like the same architecture I keep coming back to when I wrote about when I actually reach for HTMX instead of React, that’s not a coincidence. The whole trend is toward shipping less JavaScript and being explicit about what runs where.
Where islands still bite me
I’d be lying if I said this was a clean migration. A few things I’ve hit.
Shared state between islands is annoying. If my CurrencyToggle needs to talk to my PricingTable, they can’t share React state, because they’re separate hydration boundaries. I ended up using Nano Stores as a shared client-side store, which the Astro docs actively recommend. It works, but it’s a mental gear-shift after years of just passing props.
The framework boundary can leak. I have one component that imports a utility from a React library that imports another React library, which pulls in react-dom at the top level. Even with client:only, the initial static render can end up with a giant <script> block if you’re not careful. I audit my dist/_astro folder after every deploy for anything suspicious.
Testing islands is still awkward. Vitest works for the React component itself, but integration tests that verify a component actually hydrated when it scrolled into view are painful. I’ve mostly given up and rely on Playwright for that layer.
And there’s still no clean story for interactive form validation across an entire page without either pulling a form library into an island or falling back to progressive enhancement with a server endpoint. I do the latter, and it’s fine, but it’s more code than a Next.js server action.
What to try this week
If you’ve got a Next.js marketing site sitting on a bloated bundle, here’s the fifteen-minute experiment I’d run. Spin up npm create astro@latest my-site, drop your hero and pricing HTML in as a single .astro file, and reimport your one truly interactive component with client:idle. Run astro build, then check dist/_astro/. If the total JavaScript is under 40 KB, you’ve got a candidate for migration. If it’s still over 200 KB, chase down the import that’s dragging in the React runtime and either delete it or gate it behind client:only.
I don’t think Astro replaces Next.js for anything with heavy per-request personalization or a real dashboard. But for anything that’s mostly static with pockets of interactivity, which is most marketing sites and honestly a lot of what I’ve been shipping as side projects on my portfolio, islands are the model I keep coming back to.
The whole point is that hydration should be a decision, not a default. I spent too many years letting the framework decide for me.