SSR islands for SvelteKit oh-jee-jee-ya
Your pages are HTML. Nothing hydrates until you say so. Mark a component and it wakes: on load, on scroll, on whatever cue you pick. Everything else stays static. No Kit client to boot, so you ship JavaScript only for what you marked.
<script>
import Counter from '$lib/Counter.svelte' with {
wake: 'load'
};
</script>
<Counter />live
How it works
Your pages are static HTML. You pick what hydrates.
Every demo below is real, running on this page as you scroll. It starts with one island and keeps building, one idea at a time, until a whole site runs on nothing more than this.
01
One attribute wakes a component
Add wake: 'load' to an import. That one component wakes up. Everything
around it is just HTML. Kill the JavaScript and only the island stops.
<script>
import Panel from '$lib/Panel.svelte' with {
wake: 'load'
};
</script>
<Panel />live island
02
It wakes when you decide, not all at once
Same attribute, different cue: load, idle, visible, a
media query. Each island's JavaScript waits for its own. Mostly-static pages stay cheap.
<script>
import Chart from '$lib/Chart.svelte' with {
wake: 'visible'
};
</script>
<Chart />each wakes on its own trigger
03
Freeze what never moves
Renders once and never moves? Mark it wake: 'none'. Server HTML, and not a
byte in the client bundle. That is a lake.
<script>
// a frozen subtree inside an island: SSR HTML, ships no client JS
import Snapshot from '$lib/Snapshot.svelte' with {
wake: 'none'
};
</script>
<Snapshot value={42} />frozen · 0 KB JS
That is the whole client side. Now hand the work to the server.
04
Hand a hole to the server
render: 'deferred' makes a server island. Rendered per request, personalized,
no client bundle. It fetches its own HTML after the shell paints.
<script>
import Greeting from '$lib/Greeting.svelte' with {
render: 'deferred'
};
</script>
<Greeting salutation="Aloha">
{#snippet ogygiaFallback()}
<p>loading…</p>
{/snippet}
</Greeting>server HTML, fetched late
fetching…
05
Let the server choose the component
A held region goes further. The server picks which component, signs the HTML, and sends it. The client paints it and never imports the options.
<script>
// the server picks the component; the client just paints it
import { Region } from 'ogygia';
import { search } from './search.remote';
let q = $state('svelte');
let result = $state(null);
</script>
<button onclick={async () => (result = await search(q))}>
Search
</button>
{#if result}
<Region of={result} />
{/if}server chooses the component
Search to fetch a component from the server.
06
And push it, live
query.live re-renders on every tick. The server pushes HTML down the wire; the
client morphs it in place. No fetch code, no polling.
// tick.remote.ts — the server pushes rendered HTML each second.
// `yield` awaits the partial, so its HTML rides the ticket (no fetch).
export const liveTick = query.live(async function* () {
let n = 1;
while (true) {
yield region(Tick, { n: n++, at: new Date().toISOString() });
await new Promise((r) => setTimeout(r, 1000));
}
});
// the island just paints the latest tick — static partials morph in place
<Region of={liveTick().current} />server pushes HTML · morphs in place
connecting…
server pushes rendered HTML · no client data code
Every region so far stands on its own. They can also share one live object.
07
Share one object across islands
Two island bundles, one live object passed as a prop. The button writes, the counter reads. No store, no event bus.
// cart.svelte.ts — a live class that can cross island boundaries
export class Cart {
items = $state([]);
get count() { return this.items.length; }
add(item) { this.items.push(item); }
// the whole opt-in: how this instance travels as a prop
static wire = import.meta.og.wire({
encode: (c) => $state.snapshot(c.items),
decode: (items) => Object.assign(new Cart(), { items }),
});
}
// page.svelte — one instance, handed to two separate islands
const cart = new Cart();
<CartCount {cart} /> <!-- reads cart.count -->
<AddButton {cart} /> <!-- calls cart.add() -->
// click Add → the count island repaints. One live object, two islands.one live object · two islands
Content
Your content is a collection
The same idea covers your writing. Define a collection once with content(),
backed by markdown, JSON, or a CMS. You query it over the wire like any other remote
function, and the bodies never ship to the client. What you render is a region, so your
content wakes on the same schedules as everything else. These docs run on it.
// collections.server.ts — one server-only definition
import { content } from 'ogygia/content';
export const docs = content({
loader: import.meta.og.loader.markdown('./docs/**/*.svx'),
schema
});
// docs.remote.ts — expose it over the wire, bodies stripped
export const docNav = withRemotes(docs).list({
map: (e) => ({ slug: e.id, title: e.data.title })
});live over the wire · no bodies shipped
One content() definition; the source decides where it comes from.
01 Markdown & islands
Prose with live components in it
<!-- posts/hello.svx — markdown, with real islands in the prose -->
<script>
import Chart from '$lib/Chart.svelte' with { wake: 'visible' };
</script>
# {frontmatter.title}
Shiki-highlighted fences, heading ids, and a TOC in `meta.headings` —
and a live island, right in the copy:
<Chart {data} />02 Typed data
JSON through the same API
// typed data, not just prose — JSON through the same API
import { content } from 'ogygia/content';
import * as v from 'valibot';
export const authors = content({
loader: import.meta.og.loader.json('./authors/*.json'),
schema: v.object({ name: v.string(), bio: v.string() })
});
const ada = await authors.get('ada'); // fully typed { name, bio }03 Any source, even live
A CMS, a REST API, a push feed
// any source — a CMS, a REST API, or a push feed
export const press = content({
schema,
loader: {
// get() carries the body; refs() is metadata only (never a body on the wire).
async get(id) { const p = await api(`/posts/${id}`); return p && { id, data: p }; },
async refs() { return (await api('/posts')).map((p) => ({ id: p.slug, data: p })); }
}
});
// pushes? add live() — a change signal; the feed re-emits on every change.
export const feed = withRemotes(press).live.list({ map: (e) => e.data });Site kit
Everything above becomes a whole site
This is where it lands. Hand site() a collection and DocsShell gives you the rest: nav built from filenames, prev/next, full-text search, versioning and
translations, sitemap.xml and llms.txt. The frame below is live.
Search it (hit /), switch the version or language, restyle it. This whole site
runs on it.
Too opinionated?
Then take it apart
DocsShell is one composition of public parts. Keep it and swap a single region for a snippet,
or drop to Frame and build your own shell from the same bricks. Versioning and
translations aren't bolted on either: they're one primitive, dimensions.
01 Dimensions
Versioning and i18n, one primitive
The V2 and EN switchers you just used? Declare the axes and hand back one outline per coordinate. The URLs, the switchers, and per-locale fallback come with it. Dimensions →
// versioning and translations are the same primitive: dimensions
import { site, dimensions } from 'ogygia/content';
export const docs = site({
outline: dimensions({
axes: {
version: { values: ['v2', 'v1'], default: 'v2', label: 'Version' },
locale: { values: ['en', 'de'], default: 'en', label: 'Language', fallback: true }
},
// one outline per coordinate — the axes compose
resolve: ({ version, locale }) => corpora[version][locale]
})
});
// /docs/routing → v2 · en (defaults serve bare)
// /docs/de/routing → v2 · de
// /docs/v1/de/routing → v1 · de02 Composition
Swap a region, or the whole shell
Every region of the shell is a snippet prop: leave it out for the built-in, pass a
snippet to replace it, pass null to remove it. Want to start from nothing? Frame is the same shell with none of the decisions made for you. Shells →
<!-- DocsShell is a composition. Keep it, swap one region: -->
<script>
import DocsShell from 'ogygia/content/docs-shell';
import { Search, ThemeToggle } from 'ogygia/content';
</script>
<DocsShell {meta} base="/docs">
{#snippet tools()}
<Search base="/docs" />
<ThemeToggle />
{/snippet}
</DocsShell>
<!-- absent = built-in · snippet = yours · null = removed.
Or drop to <Frame> and build the shell from the same bricks. -->03 The bricks
Every part imports on its own
Sidebar, OnThisPage, Search, Switcher, Pager, ThemeToggle, Doc, TabGroup.
Each one is tree-shakeable, and ships zero CSS until you import it. Components →
Start with everything: npx ogygia site init. Keep only what you use.
The app layer
And it is still a fast app
The shell is what you see. Underneath, ogygia makes the page itself fast: prerender the shell, batch the server holes, and navigate like a single-page app, all without writing extra client code.
01
Bake the shell, fill the holes
Partial prerendering serves a static file from the CDN, with server islands fetched live per visitor. A reload demo shows one page telling two times. Partial prerendering →
02
One request per navigation
The SPA router pulls a whole page's server-island holes down one batch, out of order, with no waterfall. Single-flight navigation →
03
Mutate and repaint in one trip
A command returns its re-rendered region in the same response, so the mounted region morphs with no follow-up fetch. Single-flight →
04
Prerender the next page on hover
Native Speculation Rules run the next page's JS and holes in a hidden tab, so the click is instant. Speculation →
Start here
The full guide moved into the docs, split by topic with live demos inline. Pick a track.