ogygia

SSR islands for SvelteKit oh-jee-jee-ya

No Kit client bootstrap. The shared runtime is a custom element plus an optional router — about 7.6 KB min+brotli. Mark components with an import attribute and they become interactive on a schedule; everything else stays server HTML.

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

<Counter />
live

Live since —

Features

We’ve got them.

  • SvelteKit native
  • No Kit patches
  • SSR islands
  • ~7.6 KB runtime
  • load · idle · visible
  • Media queries
  • Server islands
  • Lakes
  • Remote functions
  • Async Svelte
  • Form actions
  • SPA router
  • View Transitions
  • Link prefetch
  • Persist layout
  • devalue props
  • Import attributes
  • Named presets
  • Nesting
  • Prerender
  • Snippets
  • csr=false HMR

What it does

SvelteKit’s default is to run client JS for the whole route. That is a strong fit for app-like pages. ogygia is for when you want the opposite authoring default: keep the page as server HTML, and opt individual components into JS.

Set csr = false so there is no Kit client bootstrap. What still loads is ogygia’s own runtime: a custom element that wakes islands, plus an optional SPA router — about 7.6 KB min+brotli together. Mark a component import with hydrate, defer, or a preset and it becomes an island: serialized props, its own client chunk, and a schedule for when JS arrives. Everything else stays server HTML.

The library does not patch Kit. It is a Vite plugin plus that small runtime and a server handle. Runtime deps are devalue, magic-string, and estree-walker. Peers are Svelte 5.40+, Kit 2.70+, and Vite 5 through 8. Kit is deep-imported for a few internals (remote wire codec, client remote entry), so treat the Kit range as tested rather than a soft semver promise.

The words

WordMeaningYou write
PageSSR HTML. No Kit client — tiny ogygia runtime (~7.6 KB).csr = false
IslandBecomes interactivehydrate: 'load' (or idle/visible/media)
LakeStatic HTML inside an islandhydrate: 'none'
Server islandHTML loaded laterdefer: 'load' (or idle/visible/media)

Nesting: island inside island shares the parent's JS. Lake freezes a subtree. Island inside a lake becomes interactive again.

Install

Install the package, register the Vite plugin before sveltekit(), and add the server handle. Then convert routes with csr = false — see Adoption for rolling that out without breaking existing Kit pages.

pnpm add ogygia
plugins: [ogygia(), sveltekit()]

order matters

vite.config.ts

ogygia() must run before sveltekit() (it also sets enforce: 'pre'). In monorepos it adds its package root to Vite's server.fs.allow so absolute shim/runtime resolves are not blocked outside the app directory. For every option, see Plugin config.

import { sveltekit } from '@sveltejs/kit/vite';
import { ogygia } from 'ogygia/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    ogygia(), // before sveltekit()
    sveltekit()
  ]
});

hooks.server.ts

ogygiaHandle() serves the signed island endpoint used by defer and lake remount: 'swr'. Compose it with sequence() if you already have handles. Override the path with ogygiaHandle({ endpoint: '/my-islands' }) if you do not want the default clash-safe emoji route.

// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { ogygiaHandle } from 'ogygia/hooks';

export const handle = sequence(ogygiaHandle(), myOtherHandle);

// On each route (or layout) you convert to islands:
// src/routes/marketing/+page.ts
export const csr = false;

Adoption

Convert one route at a time. Existing Kit pages keep working — including with <OgygiaRouter /> in the root layout.

ogygia is not an all-or-nothing flip. Wire the plugin and handle once, then opt routes into islands when you are ready. Everything else stays ordinary SvelteKit (csr = true by default).

One route at a time

On a route (or layout group) you want as an islands shell, set export const csr = false and mark the interactive imports with hydrate / defer / preset. Sibling routes with no such export keep Kit’s client bootstrap and hydrate as they do today.

A layout-level csr = false applies to every child until a deeper layout or page sets csr = true again — useful when a whole section is ready (docs, marketing) while /app stays Kit.

// src/routes/+layout.svelte — safe on mixed apps
<script>
  import { OgygiaRouter } from 'ogygia';
</script>

<OgygiaRouter />
{@render children()}

// src/routes/blog/+page.ts — convert one route at a time
export const csr = false;

// src/routes/blog/+page.svelte
<script>
  import Comments from '$lib/Comments.svelte' with { hydrate: 'visible' };
</script>

<article>…SSR content…</article>
<Comments />

// src/routes/dashboard/+page.ts — leave alone (Kit default)
// no `csr = false` → full Kit client, router stays idle

Root router without breaking Kit pages

You can render <OgygiaRouter /> in the outermost layout even while most routes are still Kit pages. The router only intercepts clicks when the document is an islands shell (no Kit bootstrap). On a csr = true page it stays idle — Kit owns navigation.

  • Islands → Kit page: full document load. Kit’s inline bootstrap cannot run after an SPA body swap, so the router hands off on purpose.
  • Islands → islands: SPA body swap (and View Transitions if enabled).
  • Kit → anywhere: Kit’s client router (or a full load), unchanged.
  • Page without the router marker: full load from an islands SPA (opt out of SPA for a subtree by omitting <OgygiaRouter /> there).

Live check: this docs site keeps the router in the root layout and ships a csr = true coexistence route.

Islands on a Kit page

An import marked with { hydrate } on a csr = true page still works — Kit hydrates the whole tree, so the island becomes a normal component (ogygia skips self-hydration and logs a dev note). Useful while you are mid-migration or sharing a component between shells. The directive earns its keep once that route is csr = false.

All-islands apps

When every route is csr = false, Kit skips its client build. ogygia detects that and runs a standalone client build so island chunks and the runtime still ship — you do not need a token csr = true page. Until then, any remaining Kit route keeps Kit’s client build available for the whole app.

Suggested order: plugin + handle → convert a low-risk content route → add the root router when you want SPA between islands pages → grow layout groups → eventually drop the last Kit route if you want a pure islands shell.

Plugin config

Everything ogygia() accepts in vite.config.ts — defaults, presets, rate limits, and signing.

Import from ogygia/vite. Put the plugin before sveltekit(). Inline import attributes only accept hydrate, defer, or preset by default (rename via importKeys if needed) — put margin, remount, and shared strategy bundles in the plugin options below.

import { sveltekit } from '@sveltejs/kit/vite';
import { ogygia } from 'ogygia/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    ogygia({
      visible: { margin: '200px' },
      presets: {
        chart: { hydrate: 'visible', margin: '200px' },
        modal: { hydrate: 'idle' },
        frozen: {
          hydrate: 'none',
          remount: { revalidate: 'idle', maxAge: '10m' }
        }
      },
      rateLimit: { max: 60, windowMs: 60_000 },
      regionTtl: 3600 // seconds; default 1h
      // sessionCookie: 'sessionid' // bind personalized defer/SWR holes
      // importKeys: { hydrate: 'ogygiaHydrate' } // only if another tool claims `hydrate`
    }),
    sveltekit()
  ]
});
OptionDefaultWhat it does
visible.marginnoneDefault rootMargin for hydrate/defer: 'visible'
presets{}Named strategy bundles referenced with preset: 'name'
importKeyshydrate / defer / presetRename those import-attribute keys if another tool already claims them
rateLimit{ max: 60, windowMs: 60_000 }Per-IP budget for the signed island endpoint; false disables
sessionCookiefalseCookie name sealed into the region MAC (opt-in)
regionTtl3600Capability URL lifetime in seconds (clamp 60–86400)

visible

visible: { margin?: string } sets the default IntersectionObserver rootMargin for every island that uses hydrate: 'visible' or defer: 'visible' without its own margin (via a preset). Same CSS margin syntax the observer accepts — e.g. '200px' or '0px 0px 100px'.

Per-island overrides belong in a preset (margin on that named config), not on the import attribute.

presets

presets is a map of names to strategy objects. Reference one from an import:

import Chart from '$lib/Chart.svelte' with { preset: 'chart' };

Each preset may include:

  • hydrate'load' | 'idle' | 'visible' | a media-query string | 'none' (lake)
  • defer — same schedule values as hydrate (server island). May pair with hydrate for a deferred client island (HTML later, then JS).
  • margin — rootMargin for this preset when the strategy is 'visible'
  • remount — lake-only (hydrate: 'none'). Strategies and swr constraints are under Remount.

Unknown preset names, unknown keys, and mixing preset with inline hydrate/defer are build errors.

importKeys

Proud defaults: import attributes use hydrate, defer, and preset. If another tool already claims one of those names on the same imports, rename only what you need:

ogygia({
  importKeys: {
    hydrate: 'ogygiaHydrate',
    defer: 'ogygiaDefer',
    preset: 'ogygiaPreset'
  }
})

Then write with { ogygiaHydrate: 'load' } (etc.) in source. Preset definitions in plugin config still use the canonical field names hydrate / defer / margin / remount — only the import-attribute spellings change. Partial overrides are fine; omitted roles keep the defaults. The three names must be distinct JS identifiers.

rateLimit

Protects the signed deferred-region / lake-remount endpoint served by ogygiaHandle(). Default is { max: 60, windowMs: 60_000 } — sixty requests per IP per minute. Pass rateLimit: false to disable (or max: 0). Values are baked into the server bundle at build time. The handle also rejects non-GET/HEAD, cross-site Sec-Fetch-Site when the browser sends it, and non-hex region ids — defense in depth around the capability URL.

sessionCookie

Opt-in. Pass a cookie name (string) to seal that cookie’s value into the region capability MAC. Harvested defer/remount URLs then fail verification without the same cookie. Empty or missing cookies stay unbound (same as the default false). Useful when personalized HTML must not be replayable from a stolen URL alone. Left off by default so prerendered defer holes remain fetchable without a request cookie — see INVARIANTS · CAPABILITY-URL. Reading cookies during deferred SSR (same-origin fetch) is unrelated — that is ordinary request context, not MAC binding.

regionTtl

Lifetime of signed region capability URLs in seconds (default 3600 — one hour). Clamped to [60, 86400]. Shorter TTLs limit replay of harvested URLs; longer TTLs keep deferred holes valid on long-lived tabs. Prerendered pages share the same window.

Props in the URL are not secret. The MAC protects integrity, not confidentiality. Do not pass tokens or PII as deferred-region props — they appear in query strings (logs, history, Referer). Region responses send Referrer-Policy: no-referrer so third-party assets inside the hole do not leak the capability URL.

OGYGIA_SECRET

Not a plugin argument — an environment variable the plugin reads at config time (.env / .env.local via Vite’s loadEnv, or a shell export).

  • Signing key for region capability URLs (defer + lake remount: 'swr'). When unset, each build gets a fresh random secret baked into the server bundle only. Set a stable OGYGIA_SECRET (≥16 UTF-8 bytes in production builds) so rolling deploys and long-lived cached HTML keep verifying. The plugin HKDF-derives separate MAC and id-salt keys from that material.
  • Region id salt — when set, island ids are not offline-computable from source paths alone.

Related server option (not on the Vite plugin): ogygiaHandle({ endpoint: '/my-islands' }) changes the path the handle serves; see Install.

Authoring

Mark an import with hydrate, defer, both (deferred client island), or a preset. Import-attribute values must be string literals (ES spec). Every usage of that marked binding is an island.

<script>
  import Counter  from '$lib/Counter.svelte'  with { hydrate: 'load' };     // island
  import Chart    from '$lib/Chart.svelte'    with { hydrate: 'visible' }; // island, later
  import Drawer   from '$lib/Drawer.svelte'   with { hydrate: '(max-width: 600px)' };
  import Report   from '$lib/Report.svelte'   with { hydrate: 'none' };    // lake (inside an island)
  import Greeting from '$lib/Greeting.svelte' with { defer: 'load' };      // server island
  import Panel    from '$lib/Panel.svelte'    with { preset: 'chart' };
</script>

<Counter start={10} />

Props cross the boundary through devalue. Date, Map, Set, BigInt, and nested plain objects survive. Functions do not. Free variables from outer scope that the island closes over are captured automatically and passed as props. Children and snippets work; a snippet defined outside an island but used inside is a build error, except the reserved server-island ogygiaFallback.

You cannot put option keys on the import itself. Margins and similar tuning belong in plugin config or a preset. Unknown presets, unknown keys, and mixing preset with another key are build errors. defer + hydrate together is a deferred client island (supported — see Server islands). hydrate: 'none' with defer is nonsense: dev warns and treats it as defer-only.

Each island is an independent Svelte app. Islands do not share reactive state. If two islands need the same data, pass it as props from the server page, or fetch inside each island (remote functions work).

The same module can be imported twice with different strategies. Per-use bindings are how you get JS for one counter on load and another instance of the same component on visible.

Nesting

An island may import another island. The inner one sits inside an already-interactive parent, so it shares the parent's JS and its own strategy is ignored (a dev-only warning names it). An island inside a lake is the opposite: the lake froze its subtree, so the inner island gets JS on its own schedule again.

You can alternate all the way down: page → island → lake → island. A server island nested inside an island renders inline with its parent (its defer is ignored there).

Editor note: the with { … } syntax needs your tsconfig.json to extend Kit's generated one (the default template already does). TypeScript 5.3+ accepts arbitrary import-attribute keys under module: "esnext", and svelte-check 4.7+ parses it cleanly.

Annotation boundary

<OgygiaBoundary> is an optional public wrapper that renders its children and nothing else — no extra DOM, no nested-island context, no hydrate / render effect. Use it only when you want to mark an island usage in source for humans (or for a future hook). It is not <svelte:boundary>, and it is not the internal lake context reset.

<script>
  import { OgygiaBoundary } from 'ogygia';
  import Counter from '$lib/Counter.svelte' with { hydrate: 'load' };
  import Report from '$lib/Report.svelte' with { hydrate: 'none' };
</script>

<!-- Transparent passthrough — marks region usages in source; zero runtime effect -->
<OgygiaBoundary>
  <Counter />
  <Report />
</OgygiaBoundary>
Hydration

Strategies

Pick when JavaScript arrives. Same schedule words — load / idle / visible / a media query — control when HTML arrives for server islands via defer. The blocks below are real islands on this page.

hydrate: 'load'

Default for critical UI. The island gets JS as soon as the ogygia-region custom element connects (after DOM ready). The island's module is part of the critical client graph for that page.

Use it for above-the-fold controls the page cannot function without: primary nav, search, the first form. Avoid sprinkling load across the whole page; every load island competes with LCP and main-thread work.

hydrate: 'load'
<script>
  import Panel from '$lib/Panel.svelte' with {
    hydrate: 'load'
  };
</script>

<Panel />
live

Live since —

hydrate: 'idle'

Defers JS until the browser is idle via requestIdleCallback, with a roughly two-second timeout and a short setTimeout fallback where idle callbacks are missing. The HTML is already on the page; only the listeners and reactive runtime wait.

Use it for secondary chrome: help panels, non-critical toggles, anything that should not delay first interaction with load islands. If the tab stays busy, the timeout still brings the island up so it cannot stall forever.

hydrate: 'idle'
<script>
  import Widget from '$lib/Widget.svelte' with {
    hydrate: 'idle'
  };
</script>

<Widget />
live

Idle after …

--:--:--

hydrate: 'visible'

JS is gated on IntersectionObserver. Until the island enters (or approaches) the viewport, it remains SSR HTML. That is the usual choice for below-the-fold charts, comment trees, related-content carousels, and heavy embeds.

Configure a default rootMargin on the plugin (visible.margin) or per preset so islands can start loading slightly before they scroll on screen. A margin like '200px' is a common pre-warm. Without a margin, JS loads at the moment of intersection.

Scroll until the visible island below intersects the viewport.
hydrate: 'visible'
<script>
  import Chart from '$lib/Chart.svelte' with {
    hydrate: 'visible'
  };
</script>

<Chart />
live

In view · —

hydrate: '(max-width: 600px)'

Any media-query string is a valid strategy. The runtime calls matchMedia: if the query already matches, the island gets JS immediately; otherwise it waits for a change event. This is how you ship mobile-only drawers or desktop-only inspectors without paying for their JS on the other viewport.

The demo island below uses (max-width: 600px). On a wide laptop it may stay static until you narrow the window. That is the strategy working as designed, not a broken preview.

hydrate: media
<script>
  import Drawer from '$lib/Drawer.svelte' with {
    hydrate: '(max-width: 600px)'
  };
</script>

<Drawer />
live

0px · no match

defer

Server islands

defer moves rendering off the page SSR and onto a signed fetch. Same schedules as hydrate — but for when HTML arrives, not when JS loads. Alone, the component’s JS never ships. Pair with hydrate for a deferred client island (HTML later, then JS on that DOM).

At page render time, only the reserved ogygiaFallback snippet is written into the document as a placeholder. The component itself is not executed yet. Props are serialized with devalue into a signed capability URL (integrity via HMAC — not confidentiality; see regionTtl). The fetch hits ogygiaHandle() on the same origin, so cookies flow and the deferred render sees a real request context. Remote functions and await work there. CSS is still collected through the page import graph.

Signing uses a per-build random key baked into the server bundle by default, or a stable OGYGIA_SECRET when set (HKDF-derived MAC key; ≥16 UTF-8 bytes in production builds — OGYGIA_SECRET). Default endpoint: /🏝️ogygia🏝️. Override with ogygiaHandle({ endpoint }). The old boolean defer: 'true' is a build error pointing at 'load'.

Deferred client islandswith { defer: '…', hydrate: '…' } — run two phases: fetch+swap on the defer schedule, then import(entry) + hydrate on the hydrate schedule. Matching schedules (load/load, idle/idle, visible/visible, same media string) coalesce: after the HTML swap, hydrate runs immediately (no second idle / IntersectionObserver / media listener). hydrate: 'load' after any defer also means ASAP after swap. Prefer this over {#await} when you need a real island boundary (signed endpoint, props, lakes); {#await} remains fine for ordinary async UI inside an already-hydrated tree. hydrate: 'none' with defer is nonsense — use defer alone (dev warns and ignores hydrate).

defer: 'load'

Fetches as soon as the region connects. Only this schedule emits a <link rel="preload" as="fetch"> hint (skipped when prerendering); the runtime reuses that preload so there is one server render.

Use it for personalized chrome that should fill in immediately: greetings, account chips, anything the first viewport expects once the shell is up. This long docs page does not mount a live defer: 'load' island — that would put the signed endpoint on the critical path for every homepage visit. See the server-islands playground for a real load fetch; the panel below is a static stand-in of the filled result.

defer: 'load' · ServerGreeting.svelte 0 KB component JS
<script>
  import Greeting from '$lib/Greeting.svelte' with {
    defer: 'load'
  };
</script>

<Greeting salutation="Aloha">
  {#snippet ogygiaFallback()}
    <p>loading…</p>
  {/snippet}
</Greeting>
server HTML swapped in
Aloha, voyager

Static stand-in · live defer: 'load' on playground

defer: 'idle'

Waits for requestIdleCallback (same ~2s timeout / short setTimeout fallback as hydrate idle) before fetching. No preload hint — the server stays quiet until the browser has spare time.

Use it for secondary personalized fragments that should not compete with LCP or critical load islands. This docs homepage uses a static stand-in; see the server-islands playground for a live idle fetch.

defer: 'idle' · ServerGreeting.svelte 0 KB component JS
<script>
  import Greeting from '$lib/Greeting.svelte' with {
    defer: 'idle'
  };
</script>

<Greeting salutation="Idle">
  {#snippet ogygiaFallback()}
    <p>waiting for idle…</p>
  {/snippet}
</Greeting>
server HTML swapped in
Aloha, Idle

Static stand-in · live defer: 'idle' on playground

defer: 'visible'

Holds the fetch until the placeholder intersects the viewport (IntersectionObserver). The server does no work for content nobody reached. Same visible.margin / preset margin as hydrate.

Use it for below-the-fold personalized blocks, related content, or heavy server fragments on long pages. Static stand-in here — live visible defer is on the playground.

defer: 'visible' · ServerGreeting.svelte 0 KB component JS
<script>
  import Greeting from '$lib/Greeting.svelte' with {
    defer: 'visible'
  };
</script>

<Greeting salutation="Visible">
  {#snippet ogygiaFallback()}
    <p>scroll to fetch…</p>
  {/snippet}
</Greeting>
server HTML swapped in
Aloha, Visible

Static stand-in · live defer: 'visible' on playground

defer: '(max-width: 600px)'

Any media-query string is a valid schedule. The runtime uses matchMedia: fetch immediately if it already matches, otherwise wait for a change. No preload hint.

The demo below is a static stand-in of a filled media result. Live media defer is on the playground (PSI’s mobile viewport matches (max-width: 600px) immediately, so a live hole on this page would fetch on the critical path).

defer: '(max-width: 600px)' · ServerGreeting.svelte 0 KB component JS
<script>
  import Greeting from '$lib/Greeting.svelte' with {
    defer: '(max-width: 600px)'
  };
</script>

<Greeting salutation="Matched">
  {#snippet ogygiaFallback()}
    <p>waiting for media…</p>
  {/snippet}
</Greeting>
server HTML swapped in
Aloha, Matched

Static stand-in · live defer media on playground

Lakes

A lake freezes HTML inside an island. No JS ships for that subtree.

Import a component with { hydrate: 'none' } and use it inside an interactive island: that subtree freezes. It server-renders inline like everything else, but its component code ships in no client chunk — the island's browser module swaps the import for a placeholder — and the runtime lifts the lake's SSR DOM out before the parent becomes interactive, then puts it back untouched. The parent island is fully interactive around static HTML.

Lake content is static after render. Props changes after the page render do nothing; event handlers inside are inert. When the parent destroys and re-creates the frozen spot (usually an {#if}), see Remount.

Where it pays: a heavy rendered markdown blob inside an interactive editor, a big SVG legend inside a live chart, a long syntax-highlighted code listing inside a collapsible panel. All the markup, none of the JavaScript. An island authored inside a lake becomes interactive again on its own schedule.

A hydrate: 'none' import used on the plain page is a no-op (the page already has no JS) and dev-warns so you notice. The value is the string 'none''false' is a build error that points you at it.

Remount

remount controls what happens when a lake’s custom element is re-created after the parent island tore it down — typically {#if show}<Lake />{/if}. It only applies to hydrate: 'none', and it is configured on a preset (not as an inline import attribute).

ShorthandOn remount
'cache'Default. Restore the SSR DOM. No network. ≡ { revalidate: false }
'empty'Leave the spot blank (no restored HTML, no fetch).
'swr'Paint cache, then revalidate. ≡ { revalidate: 'load' }

Object form (shared by cache + SWR):

remount: { revalidate?: false | schedule, maxAge?: number | '30s' | '5m' | '1h', onExpire?: 'empty' | 'fetch' }

  • revalidatefalse (or omit with only maxAge) = pure cache; a schedule ('load' | 'idle' | 'visible' | media) = SWR after painting stale.
  • maxAge — how long the client lake cache may be shown. Number = ms, or a duration string.
  • onExpire — past maxAge: 'empty' (default for cache) leaves the spot blank; 'fetch' (default for SWR) skips stale and hits the endpoint. 'fetch' requires a revalidate schedule.

swr / revalidate: schedule reuse the signed region endpoint (ogygiaHandle(), rateLimit, sessionCookie, OGYGIA_SECRET). Capability URLs are SSR-minted only (default TTL 1h via regionTtl). Islands inside the lake wait for the revalidate swap before they get JS.

Fetch-path constraints (build errors otherwise): the lake usage must be a leaf — no children / snippets — and only plain serializable attributes or spreads (no bind:, no event/callback props). The component path must resolve ($lib/… or relative). If props cannot cross the wire at mint time, the endpoint is omitted and remount behaves like 'cache'.

// vite.config.ts
ogygia({
  presets: {
    frozen: { hydrate: 'none' }, // remount: 'cache'
    blank: { hydrate: 'none', remount: 'empty' },
    // cache until TTL, then blank
    brief: {
      hydrate: 'none',
      remount: { revalidate: false, maxAge: '5m' }
    },
    // SWR: paint stale, refetch on idle; past 10m skip stale and fetch
    live: {
      hydrate: 'none',
      remount: { revalidate: 'idle', maxAge: '10m', onExpire: 'fetch' }
    }
  }
});

// inside an island
<script>
  import Report from '$lib/Report.svelte' with { preset: 'live' };
  let show = $state(true);
</script>

{#if show}
  <Report title="Q4" />
{/if}

Data, forms, remote functions

Server data flows in as props. Interactivity talks back through Kit's own remote functions — real Kit code, not an imitation.

The boring path first: +page.server.ts loads run on every request, the page renders their data, and islands receive whatever you pass them as devalue-serialized props. Classic form actions work untouched on csr = false pages — a plain <form method="POST"> submits natively with zero JS, the SPA router does not intercept form posts, and post-redirect-get lands where it should. This is the most robust interactivity on the page and it costs nothing.

Inside islands, every .remote.ts primitive works, in both build modes. The client side reuses Kit's own primitives and wire codec (deep-imported, not patched), plus your app's universal transport hook — so custom types and File arguments round-trip exactly. query resolves during SSR in-process, and its result is seeded into the client cache so the island adopts what is already on screen instead of re-fetching (no flash of pending). query.live streams over SSE with a reactive .current. query.batch collapses simultaneous calls into one request. command mutates and pairs with .refresh(). form() gives you the spreadable form object, field API, validation issues, pending state, and a no-JS fallback post. prerender() bakes data at build time — on a page that is not itself prerendered, declare it { dynamic: true } or the runtime request has no static response to hit. On SPA navigation, SSR remote seeds clear before the body swap and Kit's query/live instance maps clear after it — so query.live opens a fresh SSE on the next page instead of reusing a spent connection. Those maps live on a globalThis singleton so Vite duplicate-module loads still share one cache.

Two operational notes. command and form POSTs pass through Kit's CSRF check, so production needs a correct ORIGIN environment variable (adapter-node and friends) — a 403 on commands in prod is almost always this. And with prerender = true on a page: normal islands get JS fine from the static HTML, server islands stay runtime placeholders (static page, personalized placeholder — the flagship combination), but anything that calls the server still needs a server at runtime; a fully static deployment needs islands that don't.

Page seed. On csr = false shells, ogygia injects a document snapshot (application/ogygia-page) so islands reading $app/state / page.data see the same client-visible contract as Kit csr = true. That is intentional — enabling ogygia is not “server-only load data” the way a stock csr = false page without islands would be.

SPA router

Opt-in. Without it, every navigation is a full document load, which is a valid way to run an islands app.

Render <OgygiaRouter /> from ogygia in a layout to intercept same-origin link clicks, swap the body, and merge the head. Islands on the incoming page connect through the custom element lifecycle; islands on the outgoing page disconnect and unmount. For putting the router in the root layout while some routes stay Kit, see Adoption · Root router.

View Transitions are on by default (viewTransitions). Pass viewTransitions={false} for a plain swap when you do not want the API — or when a browser lacks support, the router falls back automatically. Same-route hash jumps (/docs#install/docs#router) skip the transition and only scroll. Cross-route navigations still use View Transitions when the target has a hash (/a/b#section); the hash scrolls after the swap.

Island code keeps the Kit imports you already know — $app/navigation, $app/state, $app/stores. goto, invalidate, beforeNavigate, and the rest work with this router.

The router does not re-execute inline <script> tags inserted by the swap (normal browser behavior for adopted nodes). Code that must run per navigation belongs in an island. Form POSTs are not intercepted; progressive enhancement keeps working.

<script>
  import { OgygiaRouter } from 'ogygia';
</script>

<!-- View Transitions on (default) -->
<OgygiaRouter />

<!-- plain swap -->
<OgygiaRouter viewTransitions={false} />

Persist layout chrome

By default every island remounts on SPA navigation. Mark durable chrome — usually in a layout — with data-ogygia-persist="key". When the same key exists on the outgoing and incoming body, the live node is kept (the new page's SSR for that key is discarded). Islands inside the persisted subtree stay mounted, so client state survives the swap.

Keys must be unique per document (first wins). Persist nodes nested inside another persist ancestor are ignored — the outer key wins. If the key is missing on either side, that subtree replaces normally. See the router playground for a side-by-side persist probe vs remounting route probe.

<!-- in a layout shared by SPA routes -->
<nav data-ogygia-persist="main-nav">
  <a href="/">Home</a>
  <!-- islands here keep their client state across nav -->
</nav>

Link prefetch

The router honours SvelteKit's data-sveltekit-preload-data and data-sveltekit-preload-code attributes, including the value grammar and ancestor inheritance you already know: eager prefetches immediately, viewport when the link scrolls into view, hover on hover (the default when the attribute is bare), tap on press, and off/false disables a broader ancestor opt-in. A prefetched page swaps in on click with no second request. Since this router delivers a page's "code" via the HTML swap itself (island chunks fetch on connect), preload-code maps to the same HTML prefetch — its extra triggers just warm the cache earlier. This site sets off on <body> and opts the sidenav back in with data-sveltekit-preload-data="hover" so nav links warm on hover without prefetching every in-page link.

Dev HMR

Under csr = false, Kit never boots a client module graph — so stock Vite HMR has nothing to talk to. ogygia() bridges that for you. No extra config.

In vite dev, the plugin injects a small bridge (virtual:ogygia/dev-hmr) that pulls in @vite/client and eager-imports app CSS under /src so Vite can soft-update those files. Kit’s FOUC bag (<style data-sveltekit>) stays in place — under csr = false that bag is how page and component CSS is delivered, and removing it blanks the document (including after SPA navigations). If Vite reports vite:error, the bridge falls back to a full document reload.

What happens on save depends on what you edited:

You changeBehavior
CSS / SCSS / etc. under src/Soft HMR — styles update without a reload
Shared modules (e.g. .ts) imported by islandsSoft HMR through the island graph
Island entry .svelte (the file you import with hydrate / defer / preset)Full reload — soft HMR through the virtual island wrapper is unreliable
Route shells (+page, +layout, +error, +server, +hooks, …)Full reload — those files never join the browser graph under csr = false
Host rewrite (add/remove/reorder island imports in a page or layout)Full reload; virtual islands for that host are invalidated so renamed components (e.g. SiteNav → SideNav) don’t keep a stale module id
Delete an island entry componentFull reload; dangling virtual islands are dropped

Production builds do not ship the bridge. Judge final paint in vite preview / a real deploy — see also Pesky patterns → Dev is not prod.

Pesky patterns

The sharp edges, stated plainly. Every one of these is enforced by a build error, a dev warning, or a documented contract — nothing here fails silently.

Captured host state is a snapshot. Do not mutate it.

Free variables an island references from host scope are serialized per-instance with devalue. That copy is one-way: writing to it inside the island updates nothing anywhere. If island markup writes to a captured variable — assignment, ++, compound assignment, destructuring assignment, or bind: — the build fails with the variable and file named. If island component code mutates a captured object, Map, or Set at runtime, a dev-only deep proxy warns once per path; production ships the plain object with zero overhead. The fix is always the same: mutable state lives inside the island ($state seeded from the prop), not on the plain page. Corollary: two islands never share reactive state. If they must agree on something, both read it from the server (props or a shared query) — or they are actually one island.

Functions and snippets do not cross the boundary

A host function referenced inside an island fails the render with the identifier named — devalue cannot serialize behaviour. A snippet defined outside an island and used inside it is a build error for the same reason (the reserved server-island ogygiaFallback snippet being the exception). Snippets authored within the island usage compile into the island itself and work exactly as normal Svelte — markup crosses as code, values cross as devalue, functions never cross.

Page-level lifecycle is dead code

On a csr = false page, +page.svelte runs only on the server. onMount, $effect, and afterNavigate written there never fire. Client behaviour belongs in islands, where the $app/navigation, $app/state, and $app/stores imports all work (backed by the router and a per-page reactive snapshot). Islands remount on every navigation with fresh values — unless you opt into data-ogygia-persist="key" on layout chrome (same key on both pages keeps the live node and any islands inside it mounted).

Inline scripts run once per document

ogygia does zero script processing. A nested inline <script> in your page HTML runs on a full document load and does not re-run after an SPA swap (standard browser behaviour for adopted nodes). Code that must run per navigation is an island — that is not a workaround, it is the model.

Choose the boundary honestly

If the whole page needs JS anyway, stop fighting: give that route csr = true and let real Kit run it — islands coexist with fully-interactive pages in the same app, and on such a page an island degrades to a normal component with a dev note. Islands earn their keep when the page is mostly content and the interactivity is patchy. The smells worth acting on: a load island on every fold (you have rebuilt hydrate-everything with extra steps), one island passing state to a sibling (should be one island), a giant island wrapping the page (should be csr = true).

No import() + with { hydrate }

Dynamic import() can take import attributes as import(mod, { with: { type: 'json' }}) — that is the language shape, and Vite can surface those options to plugins. It is not how you author an island. Region keys (hydrate / defer / preset) only apply on a static import X from '…' with { … } paired with a static <X /> tag so SSR can emit the shell. Vite strips attributes from emitted dynamic imports for browser compatibility; runtimes reject unknown keys like hydrate if they remain. ogygia therefore fails the build if it sees import(…, { with: { hydrate|defer|preset }}) — no silent no-op.

Want a chunk only after a click? That is client-only lazy mount: a host island does plain await import('./Comp.svelte') (no region attributes) and renders <Comp />. What you get is a regular Svelte component in that island’s tree — Vite splits the chunk; no second island, no SSR shell for the lazy piece. Live demo: Playground → Client-only lazy mount.

<!-- +page.svelte — host is the island -->
<script>
  import Host from '$lib/Host.svelte' with { hydrate: 'load' };
</script>
<Host />

<!-- Host.svelte — inside the island -->
<script>
  import type { Component } from 'svelte';
  let Lazy = $state();

  async function load() {
    // plain component — NOT an island (no with { hydrate })
    Lazy = (await import('./Widget.svelte')).default;
  }
</script>

<button type="button" onclick={load}>Load</button>
{#if Lazy}
  {@const Comp = Lazy}
  <Comp />
{/if}

To delay an actual island boundary until click, keep the static region import and gate the tag with {#if} instead:

<script>
  import Widget from '$lib/Widget.svelte' with { hydrate: 'load' };
  let show = $state(false);
</script>

<button type="button" onclick={() => (show = true)}>Mount island</button>
{#if show}
  <Widget />
{/if}

Dev is not prod, in two places

The SSR query seed (the no-refetch trick) works in production builds; under vite dev, module isolation keeps the seed from reaching Kit's cache, so dev islands re-fetch when they get JS — cosmetic, dev-only, documented. And Vite's dev server compiles lazily, so first paints in dev can flash unstyled in ways prod never does. Judge visual behaviour in vite preview. HMR under csr = false is real (soft CSS / shared modules, full reload for route shells and island entries) — see Dev HMR.

Constraints & coupling

What the library leans on, and how hard.

ogygia does not patch Kit or Svelte. It does deep-import Kit internals — the remote wire codec and the client remote-functions entry — by absolute path, which is why @sveltejs/kit is a peer with a deliberately tested range (>=2.70.2 <3): a Kit minor can move an internal. Pin Kit; bump deliberately; the verify suite tells you in a minute whether a bump is safe. Svelte 5.40+ (runes, createContext, async SSR) and Vite 5–8 are the other peers. Runtime dependencies are three small libraries: devalue, magic-string, estree-walker.

Kit's experimental flags for remote functions and async SSR are optional — enable them only if your app uses .remote.ts or top-level await in components. Both are still upstream-experimental; the coupling section of the README carries the current status. Prerendering, adapter-node, and Vercel-style adapters are exercised; anything serverless works for client islands, but server islands need a running origin (and remotes need a server too). Kit skips its client build when every route is csr = false; ogygia detects that and runs its own standalone island build, so an all-islands app needs no token csr page.

Generated island wrappers are Vite virtual modules (virtual:ogygia/island/<id>.svelte) — they are not written under src/. Sourcemaps and tooling should treat them as virtual, not as missing on-disk .ogygia/ files.