Sprinkling Svelte on Astro: Two Islands That Earn Their Hydration
I built this site on Astro for one reason: it ships almost no JavaScript. The blog, the CV, the now page — they’re all rendered to HTML at build time and served as static files. No framework runtime, no hydration tax, no megabytes of bundle for a page that’s mostly text.
That’s the right default for content. But every once in a while, a piece of a page genuinely benefits from being interactive — a live clock, a fuzzy search, a chart you can hover. The honest question becomes: how do I add interactivity without giving up everything that made the static-first approach worth it?
Astro’s answer is islands. My answer, for the islands themselves, turned out to be Svelte.
Why Svelte specifically
I’ve worked with React, Vue, and Angular professionally over the years. They’re all capable. But for a personal site where I want to add small, self-contained interactive bits, none of them feel right. They bring a runtime, a mental model, and a build pipeline that’s heavier than what the actual problem needs.
Svelte appeals to me for the same reasons Astro does:
- It’s a compiler, not a runtime. Svelte components compile to small chunks of vanilla JavaScript that update the DOM directly. There’s no virtual DOM, no reconciliation pass, no framework runtime to ship alongside your code. A Svelte 5 component with a few reactive bits compiles down to roughly the size of the equivalent hand-written JS plus a small shared client helper.
- The component file looks like the platform. A
.sveltefile has three sections: a<script>block (TypeScript, if you want), markup that’s just HTML with a few extra constructs, and a<style>block that’s scoped CSS. No JSX, no template DSL to learn separately, no decorators. If you can read HTML and TypeScript, you can read Svelte. - Reactivity is a language feature, not an API. In Svelte 5, you write
let count = $state(0)andcount++updates the DOM. You writeconst doubled = $derived(count * 2)and it tracks dependencies automatically. There’s nouseState, nouseMemo, no dependency arrays to get wrong. The compiler does the bookkeeping. - It’s small. I’m not adding a framework. I’m adding a tool that lets me write a few interactive components and stops bothering me after that.
This isn’t a knock on React. React earns its weight on the products I build for clients. But on this site I want the smallest thing that lets me write a hover tooltip without writing 200 lines of vanilla DOM code.
How Astro islands actually work
Before the islands themselves, it’s worth explaining what “island” means in Astro, because the term gets thrown around loosely.
By default, every Astro page is rendered to HTML at build time and ships zero JavaScript to the browser. Components written in .astro files are server-only — they execute during the build, produce HTML, and disappear.
When you import a component from a UI framework (React, Vue, Svelte, Solid, etc.) into an Astro page, Astro will still render its initial HTML at build time. By default, that’s all that ships — just static HTML. To make the component actually interactive in the browser, you add a client:* directive:
<NowStatus client:visible githubUser="Lillevang" />client:visible tells Astro: “render this to HTML now, but also ship its JavaScript and hydrate it once the user scrolls it into view.” Other options are client:load (hydrate immediately), client:idle (wait until the browser is idle), and client:only (skip server rendering entirely).
The result is what the architecture name implies: a sea of static HTML with small islands of interactivity, each loaded independently, each carrying only its own JS. The blog page ships zero KB of framework JS. The skills page ships the Svelte runtime (about 1 KB gzipped) plus my one component (about 5 KB gzipped). That’s it.
Adding Svelte to an Astro project is two commands:
npm i @astrojs/svelte svelte// astro.config.mjs
import svelte from '@astrojs/svelte';
export default defineConfig({
integrations: [svelte()]
});From that point on, any .svelte file you import into an .astro page works as an island.
Island #1: the live “now” page
The /now page is mostly a static list of what I’m currently focused on. But the top of the page now has four small status tiles that are genuinely live: my latest GitHub push, the latest blog post, what version of the site is currently deployed, and the local time in Allerød.
The interesting part isn’t any single tile — it’s how naturally the data sources mix. Some of the data is known at build time (the latest blog post comes from Astro’s content collection; the deployed version comes from a build argument). Some of it has to be fetched in the browser (GitHub’s events API). Some of it has to keep ticking (the clock).
In Astro, that maps cleanly to: collect the build-time data in the .astro page’s frontmatter and pass it as props, then let the Svelte island handle whatever needs to happen in the browser.
---
import NowStatus from "../components/NowStatus.svelte";
import { getCollection } from "astro:content";
const posts = await getCollection("blog");
const latest = posts.sort(/* by date desc */)[0];
const latestPost = latest && {
title: latest.data.title,
slug: latest.slug,
date: new Date(latest.data.date).toISOString(),
};
const deployedVersion = process.env.SITE_VERSION ?? "dev";
---
<NowStatus
client:visible
githubUser="Lillevang"
latestPost={latestPost}
deployedVersion={deployedVersion}
/>Inside the Svelte component, the GitHub fetch is just onMount plus fetch, with a small localStorage cache so I don’t pound GitHub’s unauthenticated rate limit. The clock is a setInterval that updates a $state variable every minute — and the component re-renders the affected text node automatically. No manual subscription, no effect cleanup ceremony beyond returning a clear-interval function.
What I want to emphasize is how little the Svelte half had to know about Astro. It’s a self-contained component that takes props and runs in the browser. Astro’s job — render the surrounding HTML, decide when to hydrate, ship only what’s needed — is invisible from inside the island.
Island #2: the skills radar
The /skills page is where the Svelte trade-off pays off most clearly. The skills data lives in a single TypeScript file (about 50 technical skills across four groups, each tagged with a “ring” — adopt, trial, assess, hold — and a description). The page renders that data two ways:
- As a tech radar — an SVG with four quadrants and four concentric rings, with each skill plotted as a coloured blip. Hovering or focusing a blip pops up a tooltip; clicking pins it; the side panel shows the description.
- As a searchable, filterable list — type to fuzzy-search across name, group, and description. Toggle group chips. Toggle ring chips. Items animate to their new positions as the filter changes.
This is the kind of feature that would be a slog to write in vanilla JavaScript. You’d have a fistful of event listeners, manual class toggles, a hand-rolled animation system, careful DOM cleanup. In Svelte 5 it’s a few hundred lines of declarative markup with state that updates itself.
A taste of what that looks like in practice — the search filter is literally this:
<script lang="ts">
let search = $state('');
let activeRings = $state(new Set(['adopt', 'trial', 'assess', 'hold']));
const filtered = $derived(
allSkills
.filter(s => activeRings.has(s.ring))
.filter(s => {
const q = search.trim().toLowerCase();
return q === '' ||
s.name.toLowerCase().includes(q) ||
s.detail.toLowerCase().includes(q);
})
);
</script>
<input type="search" bind:value={search} />
<ul>
{#each filtered as s (s.name)}
<li animate:flip={{ duration: 200 }}>
<strong>{s.name}</strong>
<p>{s.detail}</p>
</li>
{/each}
</ul>That’s the whole reactive loop. bind:value={search} two-way-binds the input to the state. $derived recomputes filtered whenever its inputs change. The keyed {#each} plus animate:flip makes the list smoothly animate items to new positions when the filter narrows or widens. There’s no useState, no useEffect, no useMemo, no dependency array. The compiler figures out what depends on what.
The radar half is just an <svg> with computed coordinates for each blip and a tooltip layer drawn last so it sits on top. Hovering a blip sets hovered = blip.name, which triggers everything else: the active blip grows slightly, the others dim, the side panel renders the description, and the tooltip fades in. Each of those is one line of conditional markup.
The whole component compiles down to a single chunk that gets loaded only on /skills, only when it scrolls into view. The blog and CV pages ship zero JavaScript related to it.
What the trade-off actually looks like
Here’s what the dev tools tell me after the changes:
- Blog, CV, homepage: 0 KB of client JavaScript. Pure HTML, as before.
/now: ~6 KB gzipped (Svelte runtime + the NowStatus component) — hydrated when scrolled into view./skills: ~16 KB gzipped (Svelte runtime + the SkillsExplorer component, which is by far the bigger of the two) — hydrated when scrolled into view.
For comparison, an empty React + ReactDOM bundle is around 45 KB gzipped before you write a single component. Vue 3 is around 35 KB. The Svelte runtime is about 1 KB and the rest is my code. That ratio is what makes the islands approach feel honest: you’re paying for what you actually wrote, not for the framework’s machinery.
When islands are wrong
Worth saying: this approach is wrong if you’re building something like Gmail or Linear, where the entire page is a stateful, networked application. There’s no static frame to be smart about. Use a real SPA framework, accept the runtime cost, move on.
Islands are right when the page is fundamentally content with a few interactive bits. A blog. A documentation site. A portfolio. A landing page with one configurator. A status board with a couple of live tiles.
The trap to avoid is treating “I added one island” as a license to add islands everywhere. Each island is a hydration cost and a JavaScript download. The skills radar earns its keep because the alternative is a giant static list nobody can navigate. The clock on /now earns its keep because seeing the local time in Allerød is part of the personality of the page. A static “Subscribe” button doesn’t earn anything by being made interactive. Be picky.
What I’d do next
I have two more islands sketched in my head: a blog search with proper fuzzy matching to replace the current naive .includes() filter, and an embedded interactive demo for a future post on OAuth flows. Both fit the pattern: a small, self-contained piece of interactivity on an otherwise static page, where the value is high enough to justify the JavaScript.
If you’re working on a content-heavy site and you’ve been holding off on interactivity because you don’t want to commit to a full SPA — Astro plus Svelte is the lightest path I’ve found that doesn’t feel like a compromise. The static parts stay static. The interactive parts get a tiny, modern, declarative tool. And the architecture makes sure the cost of one doesn’t leak into the other.