Skip to content

Regions

Client islands

Mark an import with `wake`, and that component ships JavaScript on a schedule. The marked binding is a portable island you can pass around like any component.

On this page

On a csr = false page nothing runs in the browser, which is what you want for most of the page but not for the parts that have to react to the visitor, such as a counter, a menu, a form widget or a chart. A client island is how you bring those parts back to life. You mark the component’s import with wake, and ogygia ships JavaScript for that component alone, on the schedule you chose, while the rest of the page stays server HTML with no client code.

<script>
  import Counter from './Counter.svelte' with { wake: 'load' };
</script>

<Counter start={3} />

Nothing else on the page changes. If a part never needs JavaScript, leave it unmarked so it stays HTML, or make it a lake when it sits inside an island.

The schedules

The word you give wake decides when the island’s JavaScript runs: load runs it immediately, visible when the island scrolls into view, and interaction on the first pointer, key or focus inside it (the click that woke it is replayed, and typed text stays). There are also idle and CSS media queries, and Timing & nesting is the full reference for all of them, including visible margins and nested regions.

Each demo below is a real island on this page with a different schedule. If you reload with JavaScript off, the markup is still there and the widgets stay static.

wake: 'load'

Live since —

wake: 'idle'

Idle after …

--:--:--

wake: 'visible'

In view · —

Each island’s $effect writes its Live since …, Idle after … or In view … stamp after it wakes, so the stamps are evidence that the component hydrated rather than baked values. You can see the idle and visible islands wake later than the load one.

The binding is the island

When you mark an import, ogygia rewrites the binding itself into a virtual island wrapper. The template still says <Counter />, but Counter is now a portable component that you can use anywhere a Svelte component goes: assign it, pass it, or render it in a list.

<script>
  import A from './A.svelte' with { wake: 'load' };
</script>

<A start={3} />

<!-- dynamic -->
{@const Active = A}
<Active start={3} />

<!-- in a list -->
{#each [{ Comp: A, props }] as { Comp, props }}
  <Comp {...props} />
{/each}

You don’t need <svelte:component>, because in Svelte 5 the marked import is an ordinary component value. Islands therefore compose the same way Svelte components do, without a special island slot or registry.

Dedupe by identity

Wrappers are deduplicated by component path plus strategy and options, not by where the tag appears or which component hosts it. A marked import used a thousand times across a page produces one wrapper module and one client entry chunk, and every instance shares that entry URL. Each instance still gets its own region and its own props payload during SSR, and a deferred island’s signature stays per instance.

Here is one marked import rendered three times on this page. The three share one client entry chunk, and each hydrates into its own independent state:

Live since —

Live since —

Live since —

Props, not children

Props are real Svelte props passed into the wrapper, and devalue serializes them into the island’s payload, so they have to be serializable: numbers, strings, plain objects and arrays. These cannot cross the boundary:

  • Functions, such as an onSave callback, because devalue cannot carry a closure. Do the work inside the island, or call a remote function.
  • Host children and snippets. Put the island’s UI, and its lakes, inside the island’s own .svelte file. The one exception is the reserved ogygiaFallback snippet on a server island.
  • Live class instances, unless the class opts in as shared state.

Constraints has the full list of what crosses the boundary.

A worked example: a cart badge

Here is a header badge that shows the cart. It hydrates on load, so it is live as soon as the page starts:

<script>
  let { count = 0 } = $props();
</script>

<span class="badge">{count}</span>
<script>
  import CartBadge from '$lib/CartBadge.svelte' with { wake: 'load' };
</script>

<CartBadge count={items.length} />

A different island, such as an “Add to cart” button, can update this badge when the two share one live object, which Shared state describes.

The boot island: run code, not UI

On a csr = false page, the script of +page.svelte or +layout.svelte never runs in the browser, so top-level onMount, $effect, listeners, telemetry and store hydration are all inert (Constraints lists the rest). An island is the only place where client code runs. When you need to run code without rendering any UI, you make an island that renders nothing. That is a boot island: a component with no markup whose only job is its $effect.

<script>
  let { dsn } = $props();

  $effect(() => {
    const t = init_telemetry(dsn);
    return () => t.stop();   // teardown runs when the island unmounts
  });
</script>
<!-- no markup -->
<script>
  import Telemetry from '$lib/Telemetry.svelte' with { wake: 'load' };
</script>

<Telemetry dsn={data.dsn} />
{@render children()}
  • Values are props. Because the page script is inert, you give the island what it needs as serializable props (dsn={data.dsn}), under the same boundary rule as any island.
  • Lifecycle follows the page. A page-level boot island runs its $effect again on each navigation, because the page remounts. A layout-level boot island usually has to continue across navigations instead of reopening a socket on every route. Add keep, and its live instance moves onto the new page without remounting:
import Telemetry from '$lib/Telemetry.svelte' with { wake: 'load', keep: 'telemetry' };

A module store often needs no boot at all

Top-level code in a .svelte.ts module runs when the first island imports it, so if an island reads your store, the store initializes itself. You need a boot island only for effects that must run on pages with no islands, such as site-wide analytics or a global keydown listener.

When another script edits a sleeping island

An island can sleep for a long time while other scripts run, such as a design-system runtime, an A/B tool or a translator. Some of them edit the DOM inside the island, removing a whitespace node or adding a comment. Svelte hydrates by walking the nodes it finds, so an edited island would normally be thrown away and rendered again in the browser, and the click that woke it would land on a dead node.

To prevent that, ogygia keeps each island’s server markup from the moment the island connects, and hydrates on wake with recovery off. If the walk fails and the live DOM differs from that markup, ogygia puts the server markup back and hydrates that instead, which matches by construction. The island gets data-og-healed and, in dev, one warning that names it. Only if that also fails does Svelte render the island client-side, as before (data-og-recovered). A heal discards whatever the other script added, so scripts should still leave <ogygia-region> subtrees alone; Who owns the DOM shows where a third-party widget can sit safely.

Do / don’t

DoDon’t
Set wake: 'visible' on islands below the fold. It costs nothing and saves work.Don’t pass callbacks or class instances as props, because they cannot cross. Move the logic inside, or use a remote function or shared state.
Keep islands small and focused on one job, since a smaller island ships less JavaScript.Don’t wrap the whole page in one island. That is csr = true with extra steps.