{"id":457,"date":"2026-07-14T05:01:53","date_gmt":"2026-07-14T05:01:53","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/astro-islands-the-partial-hydration-setup-that-killed-my-react-bundle\/"},"modified":"2026-07-14T05:01:53","modified_gmt":"2026-07-14T05:01:53","slug":"astro-islands-the-partial-hydration-setup-that-killed-my-react-bundle","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/astro-islands-the-partial-hydration-setup-that-killed-my-react-bundle\/","title":{"rendered":"Astro Islands: The Partial Hydration Setup That Killed My React Bundle"},"content":{"rendered":"<p>Confession: I spent about three months last summer telling myself that my marketing site&rsquo;s 340 KB React bundle was fine. It wasn&rsquo;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.<\/p>\n<p>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&rsquo;t a page-level decision. It&rsquo;s a component-level one.<\/p>\n<p>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.<\/p>\n<h2 id=\"what-an-island-actually-is-in-code-not-marketing\">What an island actually is (in code, not marketing)<\/h2>\n<p>Astro&rsquo;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&rsquo;s it. The rest of the page stays as static markup and never boots a framework runtime.<\/p>\n<p>Here&rsquo;s the pattern I use most. My page is a <code>.astro<\/code> file (HTML plus a bit of frontmatter for the server-side bits), and I import React components only where I actually need interactivity:<\/p>\n<pre><code class=\"language-astro\">---\n\/\/ src\/pages\/pricing.astro\nimport Layout from '..\/layouts\/Layout.astro';\nimport PricingTable from '..\/components\/PricingTable.astro';\nimport CurrencyToggle from '..\/components\/CurrencyToggle.tsx';\n---\n\n&lt;Layout title=&quot;Pricing&quot;&gt;\n  &lt;h1&gt;Pick a plan&lt;\/h1&gt;\n  &lt;CurrencyToggle client:idle \/&gt;\n  &lt;PricingTable \/&gt;\n&lt;\/Layout&gt;\n<\/code><\/pre>\n<p><code>PricingTable.astro<\/code> renders on the server and ships as HTML. <code>CurrencyToggle.tsx<\/code> is a React component, but it only gets hydrated because I added <code>client:idle<\/code>. Without a <code>client:*<\/code> directive, the React would render server-side and then ship as HTML with no runtime.<\/p>\n<p>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:<\/p>\n<pre><code class=\"language-tsx\">\/\/ app\/pricing\/page.tsx\n'use client';\nimport { useState } from 'react';\nimport PricingTable from '.\/PricingTable';\nimport CurrencyToggle from '.\/CurrencyToggle';\n\nexport default function Page() {\n  const [currency, setCurrency] = useState('USD');\n  return (\n    &lt;&gt;\n      &lt;h1&gt;Pick a plan&lt;\/h1&gt;\n      &lt;CurrencyToggle value={currency} onChange={setCurrency} \/&gt;\n      &lt;PricingTable currency={currency} \/&gt;\n    &lt;\/&gt;\n  );\n}\n<\/code><\/pre>\n<p>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 <a href=\"https:\/\/docs.astro.build\/en\/concepts\/islands\/\" rel=\"nofollow noopener\" target=\"_blank\">official islands documentation<\/a> has a solid explanation of how the compiler handles this if you want the deeper dive.<\/p>\n<h2 id=\"when-i-reach-for-clientidle-vs-clientvisible-vs-clientonly\">When I reach for <code>client:idle<\/code> vs <code>client:visible<\/code> vs <code>client:only<\/code><\/h2>\n<p>The client directives sound like they&rsquo;d overlap. They don&rsquo;t. My rough rules after six months:<\/p>\n<p><code>client:load<\/code> 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.<\/p>\n<p><code>client:idle<\/code> hydrates during the browser&rsquo;s <code>requestIdleCallback<\/code>. This is my default for anything above the fold that isn&rsquo;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.<\/p>\n<p><code>client:visible<\/code> 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.<\/p>\n<p><code>client:only=\"react\"<\/code> skips server rendering entirely and renders only on the client. I reach for this when a component depends on <code>window<\/code> or a client-side store like <code>localStorage<\/code> that would throw during SSR.<\/p>\n<p>The one I got wrong at first was <code>client:load<\/code>. I was using it out of caution, and it was quietly killing my TTI numbers. Switching those to <code>client:idle<\/code> was a five-minute change that shaved 200 ms off my interactive time on a mid-tier Android device. That&rsquo;s the kind of change I&rsquo;d have hated to make in a Next.js codebase, where hydration is more or less all-or-nothing per route.<\/p>\n<h2 id=\"server-islands-the-async-chunk-i-didnt-know-i-wanted\">Server islands: the async chunk I didn&rsquo;t know I wanted<\/h2>\n<p>Server islands landed properly in <a href=\"https:\/\/astro.build\/blog\/astro-5\/\" rel=\"nofollow noopener\" target=\"_blank\">Astro 5<\/a> and they&rsquo;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&rsquo;s cart badge, a personalized greeting) can render as separate async chunks <em>after<\/em> the main HTML ships.<\/p>\n<p>The syntax is a single prop:<\/p>\n<pre><code class=\"language-astro\">---\n\/\/ src\/pages\/index.astro\nimport Layout from '..\/layouts\/Layout.astro';\nimport Hero from '..\/components\/Hero.astro';\nimport CartBadge from '..\/components\/CartBadge.astro';\n---\n\n&lt;Layout title=&quot;Home&quot;&gt;\n  &lt;Hero \/&gt;\n  &lt;CartBadge server:defer&gt;\n    &lt;span slot=&quot;fallback&quot;&gt;Cart&lt;\/span&gt;\n  &lt;\/CartBadge&gt;\n&lt;\/Layout&gt;\n<\/code><\/pre>\n<p>The static parts stream instantly. The <code>CartBadge<\/code> 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&rsquo;t block the initial paint.<\/p>\n<p>This is what Next.js Partial Prerendering promises, and Astro&rsquo;s version is simpler to reason about. There&rsquo;s no <code>unstable_<\/code> prefix and no debate about which components can and can&rsquo;t be dynamic. If Astro server islands sound like the same architecture I keep coming back to when I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/htmx-vs-react-2026-when-i-actually-reach-for-htmx\" rel=\"noopener\">when I actually reach for HTMX instead of React<\/a>, that&rsquo;s not a coincidence. The whole trend is toward shipping less JavaScript and being explicit about what runs where.<\/p>\n<h2 id=\"where-islands-still-bite-me\">Where islands still bite me<\/h2>\n<p>I&rsquo;d be lying if I said this was a clean migration. A few things I&rsquo;ve hit.<\/p>\n<p>Shared state between islands is annoying. If my <code>CurrencyToggle<\/code> needs to talk to my <code>PricingTable<\/code>, they can&rsquo;t share React state, because they&rsquo;re separate hydration boundaries. I ended up using <a href=\"https:\/\/github.com\/nanostores\/nanostores\" rel=\"nofollow noopener\" target=\"_blank\">Nano Stores<\/a> as a shared client-side store, which the Astro docs actively recommend. It works, but it&rsquo;s a mental gear-shift after years of just passing props.<\/p>\n<p>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 <code>react-dom<\/code> at the top level. Even with <code>client:only<\/code>, the initial static render can end up with a giant <code>&lt;script&gt;<\/code> block if you&rsquo;re not careful. I audit my <code>dist\/_astro<\/code> folder after every deploy for anything suspicious.<\/p>\n<p>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&rsquo;ve mostly given up and rely on Playwright for that layer.<\/p>\n<p>And there&rsquo;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&rsquo;s fine, but it&rsquo;s more code than a Next.js server action.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>If you&rsquo;ve got a Next.js marketing site sitting on a bloated bundle, here&rsquo;s the fifteen-minute experiment I&rsquo;d run. Spin up <code>npm create astro@latest my-site<\/code>, drop your hero and pricing HTML in as a single <code>.astro<\/code> file, and reimport your one truly interactive component with <code>client:idle<\/code>. Run <code>astro build<\/code>, then check <code>dist\/_astro\/<\/code>. If the total JavaScript is under 40 KB, you&rsquo;ve got a candidate for migration. If it&rsquo;s still over 200 KB, chase down the import that&rsquo;s dragging in the React runtime and either delete it or gate it behind <code>client:only<\/code>.<\/p>\n<p>I don&rsquo;t think Astro replaces Next.js for anything with heavy per-request personalization or a real dashboard. But for anything that&rsquo;s mostly static with pockets of interactivity, which is most marketing sites and honestly a lot of what I&rsquo;ve been shipping as <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">side projects on my portfolio<\/a>, islands are the model I keep coming back to.<\/p>\n<p>The whole point is that hydration should be a decision, not a default. I spent too many years letting the framework decide for me.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I moved my marketing site from Next.js to Astro islands and cut the JavaScript bundle by 91%. Here&#8217;s the partial hydration setup I actually ship in 2026.<\/p>\n","protected":false},"author":2,"featured_media":456,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I moved my marketing site from Next.js to Astro islands and cut the JavaScript bundle by 91%. Here's the partial hydration setup I actually ship in 2026.","rank_math_focus_keyword":"astro islands","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,165],"tags":[309,507,38,44,509,19,508,422,510,172],"class_list":["post-457","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-javascript","tag-astro","tag-astro-islands","tag-frontend","tag-javascript","tag-partial-hydration","tag-performance","tag-server-islands","tag-ssr","tag-static-site","tag-web-development-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/457","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=457"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/457\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/456"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=457"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=457"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=457"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}