Skip to content

Why Astro & Islands

The problem: shipping a framework to render text

Section titled “The problem: shipping a framework to render text”

Most of the web is content: blogs, docs, marketing sites, e-commerce listings. Building those with a client-rendered SPA means the browser downloads a large JavaScript bundle, boots the framework, and re-renders — on the client — HTML that could have been sent as plain HTML in the first place.

The cost is real: slower first paint, delayed interactivity (the “uncanny valley” where the page looks ready but doesn’t respond), and a lot of CPU spent hydrating parts of the page that are never interactive — a footer, an article body, a nav bar.

The insight Astro is built on: for content sites, the HTML is the product. JavaScript should be the exception, not the substrate.

Astro renders the whole page to static HTML, then treats each interactive component as an island — a small, independently-hydrated piece in a sea of static HTML.

flowchart TB
  page["Page: static HTML (no JS)"]
  page --> nav["nav — static"]
  page --> article["article body — static"]
  page --> search["SearchBox — island (hydrated)"]
  page --> cart["CartWidget — island (hydrated)"]
  page --> footer["footer — static"]
A page is static HTML with a few hydrated islands

Two properties define an island:

  • Isolated. Each island hydrates on its own, with its own JavaScript, independent of the others. A slow-to-load carousel doesn’t block a search box.
  • Opt-in. Nothing is interactive unless you mark it so (with a client directive — a whole later lesson). The default is static HTML with no JavaScript at all.

Contrast this with a SPA, where the entire page is one JavaScript application that must boot before anything works. Islands ship JavaScript only for the components that need it, and only that much.

This is the headline: an Astro component, by default, produces HTML and CSS and no JavaScript. Write a component with a button and an onClick, and — unless you turn it into an island — the button renders, but the handler never ships. That’s not a limitation; it’s the point. You’re forced to be deliberate about what runs on the client.

---
// This component runs on the server to produce HTML.
// None of this JavaScript is sent to the browser.
const posts = await getPosts();
---
<ul>
{posts.map((post) => <li>{post.title}</li>)}
</ul>
<!-- Ships: an HTML <ul>. Ships: no JavaScript. -->

The result is pages that are fast by construction: the browser gets HTML it can paint immediately, and downloads JavaScript only for the islands you explicitly opted into.

“Zero JS by default” is a checkable claim, not a slogan — build two pages side by side and read dist/. First, a page with zero islands (a plain list, server-rendered):

src/pages/zero-js.astro
---
const posts = [{ title: 'One' }, { title: 'Two' }];
---
<html><body>
<ul>{posts.map((p) => <li>{p.title}</li>)}</ul>
</body></html>

Second, the same shell with one island — a Preact counter hydrated with client:visible:

src/components/Counter.tsx
import { useState } from 'preact/hooks';
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>Count: {n}</button>;
}
src/pages/one-island.astro
---
import Counter from '../components/Counter.tsx';
---
<html><body>
<Counter client:visible />
</body></html>

A real astro build of both, then the real output tree:

$ npx astro build --outDir dist
$ find dist/client -type f | sort
_astro/Counter.D0Rq_C0J.js
_astro/client.CYoOuZrL.js
_astro/hooks.module.C-rKEPwH.js
_astro/preact.module.Bl7PEaKa.js
_astro/signals.module.r9f66GF9.js
favicon.ico
favicon.svg
index.html
one-island/index.html
zero-js/index.html
$ grep -o '<script[^>]*>' dist/client/zero-js/index.html
(no output — no <script> tag at all)
$ grep -o '<script[^>]*>' dist/client/one-island/index.html
<script>
<script>

zero-js/index.html ships no _astro/*.js chunk and no <script> tag — it’s HTML, full stop. Adding exactly one island (one-island.astro) is what pulls in the Preact runtime, the island’s own component chunk, and the client hydration bootstrap — five JS files for the whole site, and every one of them traces back to that single client:visible. Delete the island and the JS disappears with it; nothing else on the page pays for it.

A generated Astro snippet often carries assumptions from React/Vue tutorials that don’t hold here. Check for:

  • Does a component use useState, useEffect, or any hook at the top level of the .astro frontmatter? Hooks only run inside a hydrated framework component (an island) — in a .astro file’s server-only script they’re either a hard error (wrong import) or dead code that never runs client-side.
  • Is a client directive (client:load, client:visible, …) present on every component the generated code expects to be interactive? A framework component (.tsx/.vue/.svelte) embedded in Astro with no directive renders its static markup only — its event handlers are silently dropped, not “just not hydrated yet.”
  • Does the explanation claim “Astro hydrates the whole page”? It doesn’t — only components explicitly marked as islands hydrate; everything else stays static HTML forever, proven above.
  • Is an onClick/onSubmit attached to a plain .astro template element (not inside an island)? That handler is server-side dead code — it’s stripped, because .astro templates never ship their own client JavaScript.

🐛 Spot the bug

// src/components/LikeButton.tsx (a Preact component)
import { useState } from 'preact/hooks';
export default function LikeButton() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? 'Liked' : 'Like'}</button>;
}
// --- used with no client directive: ---
// src/pages/index.astro
// <LikeButton />
Show the answer

No client directive is set on <LikeButton />, so Astro renders it once on the server and ships only the resulting static <button>Like</button> HTML — the useState hook and the onClick handler never reach the browser at all. Clicking the button does nothing; there’s no JavaScript listening. The fix is one attribute: <LikeButton client:visible /> (or client:load/client:idle), which tells Astro to also ship this component’s JS and hydrate it — proven above, hydrating an otherwise-static page is exactly what pulled in the five _astro/*.js files.

An Astro page starts as HTML with the interactivity surgically removed, not a JavaScript app with the HTML added back. Every component defaults to “printed and done”; you have to explicitly say “keep this one alive in the browser” with a client directive. That inversion — opt-in JS instead of opt-out — is the entire mechanism behind the build proof above.

Islands (Astro) Full SPA
JS shipped for a static section None The whole app bundle, even for a footer
Interactivity granularity Per-component, opt-in All-or-nothing, on by default
Time-to-interactive for content pages Fast — HTML paints immediately Slower — framework must boot first
Best fit Content-driven sites with pockets of interactivity App-like UIs where nearly everything is stateful
Cost of a highly-interactive page Large islands start to defeat the purpose This is the framework’s home turf
  • “Astro hydrates the whole page, just efficiently.” No — proven above: a page with zero islands ships zero _astro/*.js files and no <script> tag. Nothing hydrates unless a component is explicitly marked as an island.
  • “A framework component (.tsx) is automatically interactive because it’s React/Preact.” It renders to static HTML like anything else unless it carries a client directive — see the LikeButton bug above.
  • “Islands architecture means every page has some JavaScript.” A page can have zero islands and ship zero JavaScript, as proven by zero-js/index.html above — islands are opt-in per component, not a guaranteed baseline.

When Astro is (and isn’t) the right tool

Section titled “When Astro is (and isn’t) the right tool”

Astro shines for content-driven sites: blogs, documentation, marketing, portfolios, e-commerce, anything where most of the page is content and interactivity is localized.

It’s a weaker fit for highly interactive app-like UIs — a figma-style editor, a trading dashboard, a realtime collaborative tool — where nearly everything is stateful and interactive. There, a full SPA framework (or Astro with very large islands, which defeats the purpose) makes more sense. Astro even lets you embed those framework components as islands when you need them.

The next lesson in Islands covers the directives (client:load, client:visible, client:idle, …) that turn a component into an island — this lesson only proved that the opt-in exists; that one covers which directive to reach for.

What is the main cost of building a content site as a client-rendered SPA?
What are the two defining properties of an island?
By default, how much JavaScript does an Astro component ship?
Which project is the WEAKEST fit for Astro?
What actually ships to the browser?
// src/components/LikeButton.tsx, a Preact component
import { useState } from 'preact/hooks';
export default function LikeButton() {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(!liked)}>{liked ? 'Liked' : 'Like'}</button>;
}
// used in a page as <LikeButton /> — no client directive
A build produced only these files for a page with zero islands. What does the absence of any _astro/*.js file confirm?
$ find dist/client -type f | sort
favicon.ico
favicon.svg
index.html
zero-js/index.html