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
| Word | Meaning | You write |
|---|---|---|
| Page | SSR HTML. No Kit client — tiny ogygia runtime (~7.6 KB). | csr = false |
| Island | Becomes interactive | hydrate: 'load' (or idle/visible/media) |
| Lake | Static HTML inside an island | hydrate: 'none' |
| Server island | HTML loaded later | defer: '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 idleRoot 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()
]
});| Option | Default | What it does |
|---|---|---|
visible.margin | none | Default rootMargin for hydrate/defer: 'visible' |
presets | {} | Named strategy bundles referenced with preset: 'name' |
importKeys | hydrate / defer / preset | Rename 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 |
sessionCookie | false | Cookie name sealed into the region MAC (opt-in) |
regionTtl | 3600 | Capability 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 withhydratefor a deferred client island (HTML later, then JS).margin— rootMargin for this preset when the strategy is'visible'remount— lake-only (hydrate: 'none'). Strategies andswrconstraints 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 stableOGYGIA_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>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.
<script>
import Panel from '$lib/Panel.svelte' with {
hydrate: 'load'
};
</script>
<Panel />Live since —
Static HTML
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.
<script>
import Widget from '$lib/Widget.svelte' with {
hydrate: 'idle'
};
</script>
<Widget />Idle after …
--:--:--
Waiting for idle
--:--:--
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.
<script>
import Chart from '$lib/Chart.svelte' with {
hydrate: 'visible'
};
</script>
<Chart />In view · —
Below the fold
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.
<script>
import Drawer from '$lib/Drawer.svelte' with {
hydrate: '(max-width: 600px)'
};
</script>
<Drawer />0px · no match
Waiting on viewport
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 islands — with { 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.
<script>
import Greeting from '$lib/Greeting.svelte' with {
defer: 'load'
};
</script>
<Greeting salutation="Aloha">
{#snippet ogygiaFallback()}
<p>loading…</p>
{/snippet}
</Greeting>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.
<script>
import Greeting from '$lib/Greeting.svelte' with {
defer: 'idle'
};
</script>
<Greeting salutation="Idle">
{#snippet ogygiaFallback()}
<p>waiting for idle…</p>
{/snippet}
</Greeting>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.
<script>
import Greeting from '$lib/Greeting.svelte' with {
defer: 'visible'
};
</script>
<Greeting salutation="Visible">
{#snippet ogygiaFallback()}
<p>scroll to fetch…</p>
{/snippet}
</Greeting>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).
<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>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).
| Shorthand | On 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' }
revalidate—false(or omit with onlymaxAge) = 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— pastmaxAge:'empty'(default for cache) leaves the spot blank;'fetch'(default for SWR) skips stale and hits the endpoint.'fetch'requires arevalidateschedule.
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 change | Behavior |
|---|---|
CSS / SCSS / etc. under src/ | Soft HMR — styles update without a reload |
Shared modules (e.g. .ts) imported by islands | Soft 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 component | Full 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.
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.