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);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.
import Panel from '$lib/Panel.svelte' with {
hydrate: 'load'
};
<Panel />Live since —
Static HTML
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.
import Widget from '$lib/Widget.svelte' with {
hydrate: 'idle'
};
<Widget />Idle after …
--:--:--
Waiting for idle
--:--:--
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.
import Chart from '$lib/Chart.svelte' with {
hydrate: 'visible'
};
<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 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.
import Drawer from '$lib/Drawer.svelte' with {
hydrate: '(max-width: 600px)'
};
<Drawer />0px · no match
Waiting on viewport
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.
import Greeting from '$lib/Greeting.svelte' with {
defer: 'load'
};
<Greeting salutation="Aloha">
{#snippet fallback()}
<p>loading…</p>
{/snippet}
</Greeting>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.