Web Dev
AstroSvelteNanostores

Svelte 5 Runes and Nanostores, Split Across Astro Islands

3 min read Chase Isley

Two reactivity primitives, one mental model: runes live inside an island, nanostores live between them. Every Astro island boots its own component runtime, so rune state stops at the island boundary — but a nanostore atom imported from a shared module is the same instance everywhere. The glue between them is a handful of lines. No adapter package needed.

Setup

npx astro add svelte
npm i nanostores

astro add wires up the integration. Nanostores has no Svelte-specific dependency to install — runes give you everything you need to subscribe.

Runes inside one island

Inside a single component, everything is a rune. $state declares reactive state, $derived computes off of it, $effect runs when dependencies change.

<script lang="ts">
  let count = $state(0);
  let doubled = $derived(count * 2);
</script>

<button onclick={() => count++}>
  {count} (doubled: {doubled})
</button>

The compiler tracks what $derived and $effect read and wires the graph up for you. No subscriptions, no dependency arrays. This is the whole story — as long as nothing else needs to read or write that count.

Nanostores between islands

The moment two islands need to share state, runes fall off the map. Each island is its own hydration unit; a $state in one has no way to reach into another. That’s the job of a module-level store.

// src/stores/cart.ts
import { atom } from "nanostores";

export const $cart = atom<number>(0);

export function addItem() {
  $cart.set($cart.get() + 1);
}

Bridging the store into runes

Runes don’t know about nanostores, but they don’t need to — a store exposes .get(), .set(), and .subscribe(), which is everything the bridge requires. Seed a $state from the current value, then mirror updates into it from an $effect:

<script lang="ts">
  import { $cart, addItem } from "../stores/cart";

  let count = $state($cart.get());

  $effect(() => {
    return $cart.subscribe((val) => {
      count = val;
    });
  });
</script>

<button onclick={addItem}>Add ({count})</button>

Two details worth pointing out:

  • $effect returns the unsubscribe function. .subscribe() hands one back, and $effect cleans up on teardown, so there’s no onDestroy to wire.
  • Writes go through the store. Any island that calls addItem (or $cart.set(...)) updates the singleton, every subscriber re-runs, every $state bridge resyncs, every $derived reacts.

Mount two sibling islands on the same page and they’ll see the same atom:

---
import AddButton from "../components/AddButton.svelte";
import CartBadge from "../components/CartBadge.svelte";
---

<AddButton client:load />
<CartBadge client:load />

Click the button in one island, the badge in the other updates. No props, no event bus — they’re both importing the same module, and the atom is a singleton inside it.

Client islands vs. server islands

This whole pattern is a client-island story. Client islands (client:load, client:visible, client:idle) hydrate in the browser, and every one of them on the page imports from the same module graph, so a shared atom is genuinely a singleton. That’s what makes AddButton and CartBadge see each other.

Server islands (server:defer) are a different animal. They render on the server per request and stream HTML down into a slot — no Svelte runtime ships for them, so runes aren’t doing anything at runtime and there’s no subscription to keep alive. A nanostore can live inside a server island, but it’s per-request server state, not shared with the browser. Treat them as read-only snapshots: good for injecting a fresh count into the initial HTML, not for reacting to clicks.

The clean split: runes for local reactivity in a client island, nanostores as the shared channel between client islands, and server islands for values you want resolved on the server before the browser gets involved.

The division of labor

Runes are for the state a component owns by itself. Nanostores are for the state multiple islands reach for. The boundary isn’t framework-level, it’s runtime-level: Astro ships each island as an isolated hydration unit, so anything crossing that seam has to live somewhere both sides can import. A plain atom is the cheapest thing that satisfies that.

Most UIs need both, and the nice thing about this combo is that neither primitive is trying to be the other. Runes don’t pretend to be global. Nanostores don’t pretend to be ergonomic for local counters. A $effect + .subscribe() bridge is all it takes to let them meet in the middle.

View All →

Keep up with the latest in tech

Get a weekly email with hand-picked resources, tutorials, and insights on modern web development directly in your inbox.

No spam, ever. Unsubscribe with one click.