ogygia

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.

load
<script>
  import Counter from '$lib/Counter.svelte' with {
    wake: 'load'
  };
</script>

<Counter />
live

Live since —

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.

Client island wake: 'load'
<script>
  import Panel from '$lib/Panel.svelte' with {
    wake: 'load'
  };
</script>

<Panel />
live island
Load island
10
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.

Schedules idle · visible · media · interaction
<script>
  import Chart from '$lib/Chart.svelte' with {
    wake: 'visible'
  };
</script>

<Chart />
each wakes on its own trigger
Idle clock
5:20:03 PM

Hydrated after idle

Visible island
0

Hydrated on intersect

Media island
0

Active when (max-width: 600px)

Interaction island
0

Hydrated on first poke

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.

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} />
frozen · 0 KB JS
Static snapshot
42

SSR HTML, no client 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.

Server island render: 'deferred'
<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.

Held region 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}
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.

Live region 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} />
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.

Shared object 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.
one live object · two islands
Count island
0 items in the shared cart
Add island

separate bundle from the count

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.

Content collection 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 })
});
live over the wire · no bodies shipped

50 pages · one content() collection · fetched over the wire

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} />
Markdown & Shiki →
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 }
Collections →
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 });
Blocks & custom sources →
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.

ogygia playground · DocsShell Open in full ↗
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 · de
02 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.