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 since —
Static HTML
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.
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.
wake: 'load' <script>
import Panel from '$lib/Panel.svelte' with {
wake: 'load'
};
</script>
<Panel />Same attribute, different cue: load, idle, visible, a
media query. Each island's JavaScript waits for its own. Mostly-static pages stay cheap.
idle · visible · media · interaction <script>
import Chart from '$lib/Chart.svelte' with {
wake: 'visible'
};
</script>
<Chart />Renders once and never moves? Mark it wake: 'none'. Server HTML, and not a
byte in the client bundle. That is a lake.
wake: 'none' <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} />That is the whole client side. Now hand the work 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.
render: 'deferred' <script>
import Greeting from '$lib/Greeting.svelte' with {
render: 'deferred'
};
</script>
<Greeting salutation="Aloha">
{#snippet ogygiaFallback()}
<p>loading…</p>
{/snippet}
</Greeting>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.
server picks the UI <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}Search to fetch a component from the server.
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.
query.live // 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} />Every region so far stands on its own. They can also share one live object.
Two island bundles, one live object passed as a prop. The button writes, the counter reads. No store, no event bus.
static [ogygia.wire] // 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.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.
ogygia/content // 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 })
});One content() definition; the source decides where it comes from.
<!-- 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} />// 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 }// 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 });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.
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.
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 · deEvery 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. -->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 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.
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 →
The SPA router pulls a whole page's server-island holes down one batch, out of order, with no waterfall. Single-flight navigation →
A command returns its re-rendered region in the same response, so the mounted region morphs with no follow-up fetch. Single-flight →
Native Speculation Rules run the next page's JS and holes in a hidden tab, so the click is instant. Speculation →
The full guide moved into the docs, split by topic with live demos inline. Pick a track.