Regions
Held regions
A region the server picks and you render like data. The server chooses the component, and the client paints it.
On this page
- Deferred (held) regions
- Where the schedule comes from
- Mountable .ts bindings
- Try it
- The region owns the wait
- Don’t await it yourself
- Crossing shapes: the wire law
- What await does
- Recipe: a lazy top-level return
- Composition: the recomposer
- Live regions
- Single-flight mutations
- A lake that can refresh
- Warm a hole early: preload(region)
- Do / don’t
Sometimes the server, not the page, knows which component should appear. A search returns either a result card or an empty state, a feed mixes several item types, or the backend decides which tile goes on a dashboard. A server island can’t help here, because it fills a fixed hole with request-specific HTML. A held region lets the server choose the component at runtime while the client renders it. The props are type-checked and travel over the wire, and the client never imports the possible components. You can think of it as ogygia’s version of RSC: the server decides the UI, and the client paints it.
<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
When you mark the import, the component becomes a signed capability. The server creates the capability in a load or a remote function, and the client fetches it, swaps the HTML in and hydrates it, all without ever importing the component. There are two markers:
with { wake: 'load' }bakes in an interactive schedule, andregion()wakes the component on that timing.with { region: 'raw' }bakes in no schedule, so the result is HTML only unless theregion()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 in at the import with a wake: mark, using the same words as a placed island:
| Mark | Meaning |
|---|---|
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 it is fetched by when you render <Region>, and the baked wake: mark, or the schedule given at the region() call, decides when it wakes.
region: 'raw' is the escape hatch, because it bakes in nothing and leaves the region() call to set the schedule with a (data) => options function. This is useful for a registry of raw components, where each component decides its timing from its own data:
region(block, data, (d) => ({ wake: d.interactive ? 'load' : undefined }));Mountable .ts bindings
A .ts binding with with { wake: … } is a real component, not only a value for region(). That means a third-party renderer can place it directly and get the <ogygia-region> shell. A page builder such as Builder.io, for example, renders a registered component with <svelte:component this={component}/>, and the component’s JavaScript loads only once it is placed and its wake schedule fires. The same binding can still cross the wire through region(), because the region metadata rides on the binding, so one shape does both jobs:
import Widget from './Widget.svelte' with { wake: 'visible' };
export const registry = [
{ name: 'widget', component: Widget }, // Builder places it via <svelte:component>; wakes on scroll
];For a named or barrel import, which with { … } cannot reach, the asRegion macro makes the same mountable binding:
import { Header } from '@company/ui';
const HeaderRegion = import.meta.og.asRegion(Header, { wake: 'visible' });with { region: 'raw' } stays a bare descriptor, which renders only through region() and not by direct placement.
Try it
The demo below is a search box that calls a remote on submit. The remote looks up the query and chooses a component on the server, an interactive PackageCard for a hit or a static EmptyResult for a miss, then signs the result and returns it. This page never imports either result component. Try searching for svelte, kit, vite or ogygia; any other query returns the static card.
Search to fetch a component from the server.
The card’s star button shows that it hydrated. The “no match” result ships as pure HTML, and if you look at the page source you will find no JavaScript chunk for it.
The region owns the wait
The rule here is that a region’s loading UI lives on the region. Fetching a held region has two asynchronous phases that end at different times. First the request runs and your remote call resolves, and then the paint happens, when the HTML lands and its stylesheet loads. Only the region sees both phases. A loading flag, an {#await} or a boundary next to the call sees only the first, so it reports “done” before the card is on screen.
The way out is not to track the wait yourself. of accepts the promise straight from the remote call, which lets the region own the whole wait, and its placeholder snippet becomes the one loading indicator from 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}This is the timeline on a slow connection, counted from submit:
| Time | Phase | What’s on screen |
|---|---|---|
0 ms | request in flight | your Searching… placeholder |
| request resolves | HTML here; CSS <link> hoisted to <head>, sheet downloading | still the placeholder |
CSS load | region paints | the styled card, in one step |
There is no blank gap, because the placeholder holds the space until the paint, and no unstyled flash, because the paint waits for the stylesheet. Since nothing outside the region claims to know when the region is ready, nothing can disagree with it. ogygia caps the stylesheet wait at a few seconds, so a broken sheet still paints instead of stalling the page.
A new search gives of a new promise. The region keeps the previous card on screen until the next result has its styles and then morphs in place, so there is no placeholder flash between results.
A held region has this window at all because it carries its own CSS. The page never imported PackageCard, so its styles are in none of the page’s stylesheets and have to travel with the region. A held region rendered in the server pass (a blocks page, SDUI) has no such window: its links go into <head> during SSR, it arrives already styled, and no placeholder is involved.
Why `placeholder`, not `ogygiaFallback`
ogygiaFallback is the reserved slot for a render: 'deferred' server island, which is a fixed hole. A held region is a value you place, so its not-ready UI is a snippet on its <Region>, which is 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 handle the wait, but you shouldn’t. The boundary stops waiting when the fetch resolves, while the region still has a paint to do (swapping the HTML in and loading its stylesheet). You end up owning a wait you can’t fully see, and your “done” signal fires early.
The runtime still protects you, because a region never paints without its styles. It holds its previous content, its placeholder or empty space until the sheet is in, so the failure mode is a pause with nothing better to show rather than a flash. The real fix is not to create the gap: give the region the promise and put the loading UI where the lifecycle lives. Boundaries, {#await} and loading flags are still the right tools for your UI around the region, but reporting the region’s readiness is not their job.
Crossing shapes: the wire law
Every example so far sends one region. The general rule covers any number:
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 and nesting make no difference to the transport. It walks the value and makes one signed capability for each region leaf it finds, wherever the leaf sits:
// 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 the region paints as soon as the response lands, without an extra request. A bare return region(…) from an async remote does the same, because the language awaits a thenable at return, which means every value returned from a remote crosses with its HTML baked in.
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. This is not a setting you choose; it follows from where the region sits. This axis never controls timing, which is the job of wake.
Recipe: a lazy top-level return
Occasionally you want one returned region to stay a plain address, so that a CDN can cache its endpoint across visitors and no per-request HTML is baked into the response. There is deliberately no library helper for this, because it is one line you can own. A region is an object whose 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 presetUse this rarely. For a hole shared across visitors, a placed server island is usually the cleaner tool, since it is fetched from the endpoint, cacheable, and stays clear of the eager/lazy distinction.
Composition: the recomposer
When the shape needs interpreting, as with a tree, a feed or a layout, ship an ordinary 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 own chrome. With await, the whole composition renders in one server pass, producing one HTML payload that includes every leaf’s stylesheet. Nested interactive leaves are self-describing islands that wake on their own schedules after they land. This works recursively for trees with children, so an entire blocks-style page can cross as one value.
Live regions
query.live can send a held region again and again over time. When you yield a held region on each tick, its rendered, signed HTML rides the channel you already have, with no fetch per tick:
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 in the first tick. After that, a raw held region (HTML only, region: 'raw') morphs in place, keeping focus and typed text, while an interactive one is kept alive, so its state stays and it does not hydrate again. Either way, one slot always shows the latest tick.
Single-flight mutations
A command that returns a held region sends the re-rendered HTML back in its own response, and a mounted <Region> at that address updates from it without a follow-up fetch. One round trip both changes the data 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
});When you call bump(id) from an island, the region on the page at that address repaints from the command’s response, with no extra GET to the endpoint.
A lake that can refresh
A site header is often a lake: static markup with islands and holes inside it that wake on their own. When something the server renders it from changes without a navigation, such as a saved language or a sign-in, render the header as a held region from a query and refresh that query from a command:
// header-region.remote.ts: its own module (see the caveats)
import { command, getRequestEvent, query } from '$app/server';
import { region } from 'ogygia';
import Header from './Header.svelte'; // a plain import, no wake: HTML only
export const headerRegion = query(async () => region(Header, {}));
export const saveLocale = command(v.string(), async (locale) => {
getRequestEvent().cookies.set('locale', locale, { path: '/' });
await headerRegion().refresh(); // the refreshed header rides this response
});<Region of={headerRegion()} />This is what happens on the refresh:
- The header renders for the page it is mounted on. The command’s request has no page render, but the region’s components still read the calling page’s
page.url,page.route,page.paramsandpage.data. ogygia looks that page up through Kit’s own data request, with the visitor’s cookies, after the command’s change has been made. A hole fetched on its own gets the same treatment. - It morphs in place. The header element stays, and so does any island inside it, along with its state. An island that reads
page.datashows the new values, because the answer carries the keys its islands read and they join the page’s data before anything wakes. - A hole refetches only when its answer can change. A hole’s address names its call and the page data its tree reads. When the locale changed, the hole fetches its new answer and keeps its current one on screen until then. When nothing changed, it keeps what it has.
Keep these caveats in mind:
- Put the region’s remote in its own module. The header’s own tree may import remotes, and the remote module imports the header. A component inside the header that needs to trigger the refresh can’t import the command, so dispatch a DOM event and call the command where the
<Region>is mounted. - On a Kit-hydrated (
csr = true) page, Kit ownspage.data. Refresh the page’s data first (await invalidateAll()) and then refresh the region, so the islands inside read the same values the header was rendered with. In dev, ogygia warns when they differ. - Only server loads count. A key returned by a universal load (
+page.ts,+layout.ts) exists only inside a full page render. In dev, a region that reads such a key names it. localsset by a load are not part of the page. The command’s request never runs the layout’s load, so set whatever the header reads fromlocalsin the remote function too.
Warm a hole early: preload(region)
preload(region) warms one hole’s HTML ahead of its schedule, so the hole can paint early while its own schedule stays lazy. Import it 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 decision about which component to show, and a server island when the component is fixed.
- Do choose a raw held region (
with { region: 'raw' }) for result UI that never needs interaction, since it ships no JavaScript. - Don’t import the result components on the client. The server sends them, and the client only renders
<Region>.