ogygia

SSR islands for SvelteKit

Ship a page shell with zero Kit JS. Mark components with an import attribute and they hydrate on their own schedule. Everything else stays plain HTML.

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

<Counter />
live

Live since —

What it does

SvelteKit's default is to hydrate the whole route. That is the right call when most of the page is interactive. It is the wrong call when the shell is mostly static copy and only a handful of widgets need JavaScript.

ogygia inverts that default. You set csr = false so the page ships as a server-rendered document with no Kit client runtime. Components you mark with an import attribute become regions: each gets serialized props, its own client chunk, and a hydration (or defer) strategy. Everything else stays inert HTML.

The library does not patch Kit. It is a Vite plugin plus a 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.

Under the hood this is the unified region model. Every boundary has two axes: render (page or defer) and hydrate (load, idle, visible, a media query, or off). The nearest boundary above a node wins. That rule is why nesting is safe: an island inside an island does not double-hydrate.

Install

Install the package, register the Vite plugin before sveltekit(), enable the experimental flags Kit needs, drop in the server handle, and turn CSR off on the routes that should be island shells.

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

order matters

vite.config.ts

ogygia() must run before sveltekit() (it also sets enforce: 'pre'). Optional knobs: a default visible.margin for IntersectionObserver, and named presets you reference from imports.

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' }
      }
    }),
    sveltekit()
  ]
});

svelte.config.js

Async SSR and remote functions are required. Without them, deferred server islands and nested region context will not compile or run correctly.

export default {
  compilerOptions: { experimental: { async: true } },
  kit: {
    adapter: adapter(),
    experimental: { remoteFunctions: true }
  }
};

Layout + hooks

csr = false is what removes Kit's client runtime from the shell. Kit skips its client build entirely when every route is csr = false. Islands still need a client build (runtime + code-split chunks), so keep at least one normal Kit route, or let ogygia run its standalone client build (both paths are supported).

ogygiaHandle() serves the signed island endpoint used by defer. 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/routes/+layout.ts
export const csr = false;

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

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

Authoring

A component becomes a region when its import carries exactly one of hydrate, defer, or preset. Import-attribute values must be string literals (ES spec). Every usage of that marked binding is a region.

import Counter  from '$lib/Counter.svelte'  with { hydrate: 'load' };
import Chart    from '$lib/Chart.svelte'    with { hydrate: 'visible' };
import Drawer   from '$lib/Drawer.svelte'   with { hydrate: '(max-width: 600px)' };
import Greeting from '$lib/Greeting.svelte' with { defer: 'load' };
import Report   from '$lib/Report.svelte'   with { hydrate: 'none' };
import Panel    from '$lib/Panel.svelte'    with { preset: 'chart' };

<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 a region but used inside is a build error, except the reserved server-island fallback.

You cannot put option keys on the import itself. Margins and similar tuning belong in plugin config or a preset. Unknown presets, unknown keys, mixing preset with another key, and defer + hydrate together are build errors (the last one is roadmap).

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

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

The one nesting rule

Every composition question has the same answer: a region hydrates itself only if the nearest region boundary above it is not hydrated. Everything below falls out of that sentence; none of it is special-cased.

An island may import another island. The inner one sits inside a hydrated boundary, so it degrades to a plain component and hydrates exactly once, with its parent; its own strategy is ignored and a dev-only warning names it. An island inside a lake is the opposite case: the lake made its subtree dead again, so the inner island self-hydrates. Alternation — shell → island → lake → island — is legal all the way down. A server island nested inside an island renders inline with its parent (its defer is ignored there; DESIGN.md records the roadmap semantics).

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.

Hydration

Strategies

Pick when JavaScript arrives. The blocks below are real regions on this page. Use the JS toggle to compare the hydrated UI with the static HTML the server shipped.

hydrate: 'load'

Default for critical UI. The runtime hydrates the region 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 hydration work on the main thread.

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

<Panel />
live

Live since —

hydrate: 'idle'

Defers hydration 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'
import Widget from '$lib/Widget.svelte' with {
  hydrate: 'idle'
};

<Widget />
live

Idle after …

--:--:--

hydrate: 'visible'

Hydration is gated on IntersectionObserver. Until the region 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, hydration starts at the moment of intersection.

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

<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 hydrates 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 region 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
import Drawer from '$lib/Drawer.svelte' with {
  hydrate: '(max-width: 600px)'
};

<Drawer />
live

0px · no match

Server islands

defer moves rendering off the page SSR and onto a signed fetch. The browser still gets HTML. It does not get that component's JS.

At page render time, only the reserved fallback snippet is written into the document. The component itself is not executed yet. Props are serialized with devalue and HMAC-signed so the endpoint can reject tampering. A <link rel="preload" as="fetch"> hint (skipped when prerendering) starts the request during HTML parse; the runtime reuses that preload.

The fetch hits the ogygia handle on the same origin, so cookies flow and the deferred render sees a real request context. Remote functions and await work during that render. CSS for the component is still collected through the page import graph and linked in <head>. On a csr = false page, zero component JS is shipped for the deferred island.

Signing uses process.env.OGYGIA_SECRET when set; otherwise a per-build key is baked into the server bundle only. The default endpoint path is /🏝️ogygia🏝️ (emoji brackets keep it from colliding with app routes).

The defer value is the fetch timing for the hole — the same scheduler vocabulary as hydrate, which is the symmetry at the heart of the region model: one axis says when HTML arrives, the other says when JS wakes. 'load' fetches immediately (and is the only value that emits the preload hint), 'idle' waits for requestIdleCallback, 'visible' holds the fetch until the hole scrolls into view — the server does no work for content nobody reached — and a media query fetches when it matches. The old boolean spelling defer: 'true' is a build error pointing at 'load'.

Good fits: personalized greetings, account chips, slow fragments on an otherwise cacheable page. Prerendered routes keep server islands as runtime holes. v1 does not hydrate after the HTML swap; pairing defer with a hydrate strategy is explicitly roadmap. Override the endpoint with ogygiaHandle({ endpoint }) if the emoji route offends your logs.

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

<Greeting salutation="Aloha">
  {#snippet fallback()}
    <p>loading…</p>
  {/snippet}
</Greeting>
server HTML swapped in
Fetching island…

Fallback while the server renders

Lakes

A lake is hydration switched off again, inside an island. Same declaration, opposite polarity.

Import a component with { hydrate: 'none' } and use it inside a hydrated 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 hydrates, then puts it back untouched. The parent island is fully interactive around a hole of dead, free HTML.

The contract is the same honesty islands demand elsewhere: lake content is furniture. Props changes after the page render do nothing; event handlers inside are inert. If the parent island destroys and re-creates the lake's spot (an {#if} toggle), ogygia({ lake_restore }) decides what happens: 'cache' (default) restores the frozen DOM from a cache, 'empty' leaves the re-created spot blank.

Where it pays: a heavy rendered markdown blob inside an interactive editor shell, 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. And because the nesting rule is uniform, an island authored inside a lake wakes up again on its own — frozen water can contain live land.

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

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 shell 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 hydration 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.

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 hydrate fine from the static HTML, server islands stay runtime holes (static page, personalized hole — the flagship combination), but anything that calls the server still needs a server at runtime; a fully static deployment needs islands that don't.

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.

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.

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.

import { OgygiaRouter } from 'ogygia';

// 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 nearest-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. Put data-sveltekit-preload-data="hover" on a nav container and the whole subtree opts in.

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 in the dead shell. 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 fallback 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). Note the snapshot semantics: 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 hydrates anyway, stop fighting: give that route csr = true and let real Kit run it — islands coexist with fully-hydrated 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 shell 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).

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 on hydration — 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.

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 must be on, and both features are 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 and remote functions need a running server. 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.