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.” There are a handful of these directives, and the choice between them is the single most direct UX/performance lever Astro gives you. I’ll come back to it.
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.
Picking a hydration directive
Earlier I glossed over client:visible as the directive I picked for everything. That deserves more attention, because the directive you choose is the most direct lever you have for shaping the perceived performance of the page. Astro gives you five:
client:load— hydrate immediately as the page loads. Costs main-thread time during the most expensive moment of the page lifecycle. Use it when interactivity is required before the user has any chance to scroll: a theme toggle in the header, a nav menu, an above-the-fold filter bar.client:idle— hydrate during the nextrequestIdleCallback. The component still hydrates “soon,” but only after the browser is done with critical work. Good for things that want to be live early but aren’t blocking interaction.client:visible— hydrate when the component scrolls into view, viaIntersectionObserver. The component costs zero hydration time on initial load. Best for anything below the fold, especially if it’s heavy. The skills radar is a textbook case: 16 KB of JavaScript I don’t want anyone to pay for unless they actually scroll to it.client:media={query}— hydrate only if a media query matches. Useful for mobile-only or desktop-only widgets.client:only="svelte"— skip server rendering entirely; ship the component blank and let the browser render it from scratch. Use this only when the component genuinely can’t render meaningfully on the server (e.g., it readslocalStoragefor its initial state). The trade-off is a flash of nothing before hydration.
On /now, those choices are real and visible. The <NowStatus> tile grid sits below the page header — client:visible is fine; people will scroll to it and pay the hydration cost only then. The small GitHub badge that sits inside the page header (just below the H1) is above the fold and visible immediately on page load — but it’s also non-essential decoration. So it gets client:idle: hydrate it soon, but not at the cost of slowing the first paint.
<header>
<h1>What I'm Focused on Now</h1>
<p>Current interests, projects, and priorities</p>
<GitHubBadge client:idle githubUser="Lillevang" />
</header>
<NowStatus client:visible githubUser="Lillevang" ... />The default I’ve settled on: client:visible for anything the user has to scroll to, client:idle for above-the-fold ornaments, client:load only when interactivity is genuinely required immediately. I haven’t needed client:only yet, and I’m deliberately wary of it.
Sharing state across islands
Here’s the thing the islands architecture actively makes harder, and the workaround is so clean that it’s almost a feature.
Each client:* directive creates an independently hydrated Svelte app. They don’t see each other. State you declare with $state inside one component is invisible to another component, even if both are on the same page. From a framework-purity standpoint that’s fine — small islands, isolated concerns. From a practical standpoint, the moment you have two islands that want to render the same data, you have a problem: do they both fetch independently? Do they coordinate? How?
The answer turns out to be the oldest trick in JavaScript: module-level state.
Modules in JavaScript are singletons. If two islands import the same module, they get the same module instance. So if that module exports a reactive value — a Svelte store — both islands see the same value, and both react to changes.
Svelte’s built-in writable from svelte/store is exactly this. It’s a tiny pub-sub primitive: you can .set() it, you can .subscribe() to it, and inside a Svelte component the $store syntax auto-subscribes and unsubscribes for you. Crucially, it’s just JavaScript. It doesn’t care about island boundaries.
Concretely, on /now I now have two islands sharing one source of GitHub data. There’s a small <GitHubBadge> tucked into the page header showing “Last push to website · 2h ago,” and below it the bigger <NowStatus> tile grid that includes a richer “GitHub activity” card. Both of them want the same API response. Without a shared store, each would fetch independently — two HTTP requests for the same data, two cache entries, two slightly different loading states.
Instead, the data lives in a small store module:
// src/stores/githubActivity.ts
import { writable } from 'svelte/store';
export const events = writable<GitHubEvent[] | null>(null);
export const loading = writable<boolean>(false);
export const error = writable<string | null>(null);
let inflight: Promise<void> | null = null;
let lastFetchTs = 0;
export function loadGitHubActivity(user: string): Promise<void> {
// Already fresh? Skip.
if (Date.now() - lastFetchTs < CACHE_TTL_MS && lastFetchTs > 0) {
return Promise.resolve();
}
// Already in flight? Coalesce — return the same promise.
if (inflight) return inflight;
loading.set(true);
inflight = fetch(`https://api.github.com/users/${user}/events/public`)
.then(/* ... set events, write cache, etc. */)
.finally(() => { loading.set(false); inflight = null; });
return inflight;
}Both islands call loadGitHubActivity() in their onMount. The first call kicks off the fetch; the second call sees inflight is non-null and returns the same promise. One network request. Both islands subscribe to the same events store and re-render together when the data arrives.
In each island the consumer code is essentially boilerplate-free:
<script lang="ts">
import { onMount } from 'svelte';
import { events, loading, loadGitHubActivity } from '../stores/githubActivity';
onMount(() => loadGitHubActivity('Lillevang'));
</script>
{#if $loading}
<span>Loading…</span>
{:else if $events}
<span>{$events.length} events</span>
{/if}The $events and $loading syntax does the subscribe/unsubscribe dance for you. When the badge in the header gets hydrated by client:idle, it kicks off the fetch. When the user scrolls down and client:visible hydrates the <NowStatus> tiles, they call the same function, find the data already loaded, and render instantly with no spinner.
If you compare this to nanostores (which Astro’s documentation often recommends for the same purpose), they’re nearly identical primitives — same set/subscribe shape, same module-singleton trick. The only reason to reach for nanostores instead is if you want to share state with islands written in other frameworks (React, Vue, Solid) on the same page, since svelte/store only auto-subscribes inside Svelte components. For an all-Svelte project, the built-in store is one less dependency and zero extra bytes — it’s already in the bundle.
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: ~7 KB gzipped — Svelte runtime, the shared GitHub-activity store, theNowStatustile grid, and the smallGitHubBadgein the header. The badge hydrates onclient:idle; the tile grid hydrates onclient:visible. Both islands share the same store, so only one network request happens./skills: ~16 KB gzipped (Svelte runtime + the SkillsExplorer component, which is by far the biggest of the lot) — 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.