Skip to content

Regions

Held regions

A server-chosen island you render like data. The server picks the component, the client paints it.

On this page

A held region is a component the server picks at runtime and the client renders: props type-checked, delivered over the wire. It is ogygia’s take on RSC: the server decides the UI, the client just paints it.

When to use

Reach for a held region when the choice of component belongs to the server: search results that are a card or an empty-state, a feed of mixed item types, a dashboard tile the backend decides. A server island fills a fixed hole with request-specific HTML; a held region lets the server choose which component fills it, and the client never imports the options.

<script>
  import { region, Region } from 'ogygia';
  import Card from './Card.svelte';
</script>

<!-- inline: rendered in this SSR pass -->
<Region of={region(Card, { id })} />

Deferred (held) regions

Mark the import and the component becomes a signed capability the server mints in a load or remote function. The client fetches it, swaps the HTML in, and hydrates, without ever importing the component itself. Two markers:

  • with { wake: 'load' } bakes an interactive schedule: region() wakes it on that timing.
  • with { region: 'raw' } bakes no schedule: HTML only, unless the region() call sets one.
// server picks the component
import PackageCard from './PackageCard.svelte' with { wake: 'load' };
import EmptyResult from './EmptyResult.svelte' with { region: 'raw' };
import { query } from '$app/server';
import { region } from 'ogygia';

export const search = query(v.string(), async (raw) => {
  const hit = DB[raw.toLowerCase()];
  // PackageCard bakes wake:'load' → interactive; EmptyResult is HTML only.
  return hit ? region(PackageCard, hit) : region(EmptyResult, { query: raw });
});
<!-- the client renders whatever it receives -->
<script>
  import { Region } from 'ogygia';
  import { search } from './search.remote';
  let result = $state(null);
</script>

<button onclick={async () => (result = await search(q))}>Search</button>
{#if result}<Region of={result} />{/if}

Where the schedule comes from

A held region’s wake schedule is baked at the import with a wake: mark, the same vocabulary as a placed island:

MarkMeaning
wake: 'load'interactive, hydrate on load
wake: 'idle'interactive, hydrate when idle
wake: 'visible'interactive, hydrate when scrolled into view
wake: '(min-width: 60rem)'interactive, hydrate when the media query matches
region: 'raw'HTML only, no baked schedule

A held region has no render mark of its own: you decide when to fetch by choosing when to render <Region>. A baked wake: mark (or the schedule at the region() call) decides when it wakes.

region: 'raw' is the escape hatch: it bakes nothing, so the region() call sets the schedule with a (data) => options function. Handy for a registry of raw components that each decide their timing from their own data:

region(block, data, (d) => ({ wake: d.interactive ? 'load' : undefined }));

Try it

The demo below is a search box. On submit it calls a remote that looks up the query, picks a component on the server (an interactive PackageCard for a hit, a static EmptyResult for a miss), signs it, and returns it. This page never imports either result component. Search svelte, kit, vite, or ogygia; anything else returns the static card.

Search to fetch a component from the server.

The card’s star button proves it hydrated. The “no match” result ships as pure HTML. View source and you will find no JS chunk for it.

The region owns the wait

One rule: a region’s loading UI lives on the region. Fetching a held region has two async phases that finish at different times: the request (your remote call resolves), then the paint (the HTML lands and its stylesheet loads). Only the region can see both; any loading flag, {#await}, or boundary you keep next to the call sees only the first, so it declares “done” before the card is on screen.

So don’t track it at all. of accepts the promise straight from the remote call, so the region owns the whole wait. Its placeholder snippet is the single loading indicator, start to finish:

<button type="submit">Search</button>   <!-- no loading state to reconcile -->

{#if query}
  <Region of={search(query)}>
    {#snippet placeholder()}<p class="hint">Searching…</p>{/snippet}
  </Region>
{:else}
  <p class="hint">Search to fetch a component from the server.</p>
{/if}

The timeline on a slow link, from submit:

TimePhaseWhat’s on screen
0 msrequest in flightyour Searching… placeholder
request resolvesHTML here; CSS <link> hoisted to <head>, sheet downloadingstill the placeholder
CSS loadregion paintsthe styled card, in one step

No blank gap (the placeholder holds the space until the paint), no unstyled flash (the paint waits for the sheet), and nothing outside the region claims to know when it’s ready, so nothing can disagree. ogygia caps the stylesheet wait at a few seconds so a genuinely broken sheet still paints rather than hanging.

A re-search hands of a new promise. The region keeps the previous card on screen until the next result is styled, then morphs in place: no placeholder flash between results.

Why a held region even has this window: it carries its own CSS. The page never imported PackageCard, so its styles are on no page stylesheet: they travel with the region. (A held region rendered in the server pass, a blocks page or SDUI, has no window at all: its links go into <head> during SSR and it arrives styled, no placeholder involved.)

Why `placeholder`, not `ogygiaFallback`

ogygiaFallback is the reserved slot for a render: 'deferred' server island (a fixed hole). A held region is a value you place, so its not-ready UI is a snippet on its <Region>. Plain Svelte.

Don’t await it yourself

With experimental.async on you can write of={await search(query)} inside a <svelte:boundary> and let its pending snippet drive the wait. Resist it. The boundary un-suspends the moment the fetch resolves. But the region still has a paint to do (swap the HTML, load its stylesheet). You’ve re-taken ownership of a wait you can’t fully see, and your “done” signal fires early.

The runtime still protects you: a region never paints unstyled (it holds its previous content, or its placeholder, or empty space until the sheet is in), so the failure mode is a hold with nothing better to show, not a flash. But the fix is to not create the gap: hand the region the promise and put the loading UI where the lifecycle lives. Boundaries, {#await}, and loading flags remain great for your UI around the region; region readiness just isn’t theirs to report.

Crossing shapes: the wire law

Everything above sends one region. The general rule for sending many:

A region is the only unit of code that crosses. Everything else is data. Any data shape crosses if its leaves are regions.

Arrays, records, nesting: the transport doesn’t care. It walks the value and mints one signed capability per region leaf it finds, wherever it finds it:

// a mixed list — each leaf its own ticket
return hits.map((h) => (h.sponsored ? region(Ad, h) : region(Card, h)));

// named slots — mix eager and lazy freely
return { header: region(Head, u), main: await region(Chart, o), rail: region(Rail, u) };

// a region as a PROP of another region — the leaf decides where it renders
return await region(FeedCard, { author, media: region(VideoEmbed, { src }) });

What await does

await region(…) renders the component on the server and bakes that HTML into the ticket, so it paints the instant the response lands: no extra request. A bare return region(…) does the same, because the language awaits any thenable at return. So anything you return from a remote crosses with its HTML baked.

Leaves nested inside a container are the exception: nothing awaits them, so they cross as a plain address and the client fetches each hole on mount. That’s not a knob you reach for. It falls out of where the region sits. (Timing is never this axis’s job. That’s wake.)

Recipe: a lazy top-level return

Occasionally you want a single returned region to stay a plain address (so its endpoint can be cached across visitors) instead of baking per-request HTML into the response. There’s no library helper for this on purpose; it’s one line you own, because a region is just an object and the eager then is non-enumerable, so a spread drops it:

const lazy = <R>(r: R): R => ({ ...r });   // strips the auto-await; keep it in your codebase

export const card = query(v.string(), async (id) => lazy(region(Card, { id })));
// now `card()` ships a reference; the client fetches /🏝️?… which a CDN can cache with a maxAge preset

Reach for this rarely. For a hole shared across visitors, a placed server island is usually the cleaner tool. It’s endpoint-fetched and cacheable without touching the eager/lazy seam at all.

Composition: the recomposer

When the shape needs interpreting (a tree, a feed, a layout), ship a normal marked component that takes the shape as props and turns it back into markup:

import FeedList from '$lib/FeedList.svelte' with { region: 'raw' };
return await region(FeedList, { items: hits.map((h) => region(cardFor(h), h)) });

FeedList renders {#each items as it}<Region of={it} />{/each} plus its chrome. Awaited, the whole composition renders in one server pass: one HTML payload carrying every leaf’s stylesheet, with nested interactive leaves as self-describing islands that wake on their own schedules after landing. This works recursively (trees with children), so a whole blocks-style page can cross as one value.

Live regions

query.live can re-emit a held region over time. yield a held region each tick, and its rendered, signed HTML rides the channel you already have, with no per-tick fetch:

export const dashboard = query.live(v.string(), async function* (id) {
  for await (const stats of feed(id)) yield region(StatCard, { stats });
});

<Region of={dashboard(id).current} /> swaps the first tick in, then a raw (HTML-only, region: 'raw') held region morphs in place (focus and typed text survive) and an interactive one is kept alive (its state survives, no re-hydrate). One slot, always the latest tick.

Single-flight mutations

A command that returns a held region sends the re-rendered HTML back in its own response. Any mounted <Region> at that address updates from it: no follow-up fetch. One round trip both mutates and repaints:

export const bump = command(v.string(), async (id) => {
  await db.increment(id);
  return region(Counter, await db.get(id));  // the fresh region rides the command's response
});

Call bump(id) from an island and the on-page region at that address repaints from the command’s own response. There is no extra GET to the endpoint.

server badge connecting…

The command mutates and returns the region in the same response — no second fetch.

Warm a hole early: preload(region)

preload(region) warms one hole’s HTML ahead of its schedule, so it can paint early while its own wake stays lazy. Import from ogygia; it takes a region value and nothing else.

import { preload } from 'ogygia';
preload(region(Recommendations, { userId }));

Do / don’t

  • Do use a held region when the server owns the which-component decision; use a server island when the component is fixed.
  • Do choose a raw held region (with { region: 'raw' }) for result UI that never needs interaction. It ships zero JS.
  • Don’t import the result components on the client. The server sends them; the client only renders <Region>.