Skip to content

Regions

Server islands

HTML fetched from the server after the page loads. Placeholder first, then real HTML. No JS is necessary.

On this page

A server island is a hole in the page. Its HTML comes from the server after the first load. The page ships a placeholder. The real HTML swaps in on a schedule. No JS hydrates it. It is server HTML that arrives late.

When to use

Use a server island when a part of the page is request-specific but not interactive: a personal greeting, a “you have 3 unread” count, a slow upstream that must not block first paint. It needs no client JS. Thus it costs less than a client island. If a part of it must be interactive, nest a client island inside it (see below). If you can prerender it, you do not need to defer it.

Mark the import with render: 'deferred':

<script>
  import Greeting from './Greeting.svelte' with { render: 'deferred' };
</script>

<Greeting name="Ada" />

Greeting renders on the server, signed on each request. Its HTML fills the hole. Use this tool when a hole depends on the request (cookies, headers, a slow upstream) but needs no interactivity.

The demo below is a real server island on this page. Its timestamp is set when the HTML of the island arrives, after the page loads. It is not set at page render. That shows the HTML came from the server later.

Loading from the server…

To see the round-trip, click Fetch again. The hole goes back to its fallback. It fetches fresh server HTML and swaps it in. The timestamp is new each time, because a deferred hole is dynamic by default.

/demo/deferred · server island (a hole)

When it fetches: the wake schedule

A deferred region fetches on the wake schedule. The timing words are the same as for an island. render: 'deferred' alone fetches on load. Add wake to make the fetch itself later. A hole below the fold does not cost a request until you scroll to it:

<script>
  import Chart from './Chart.svelte' with { render: 'deferred', wake: 'visible' };
</script>
wakeHTML arrives
load (default)immediately
idlewhen the browser is idle
visiblewhen scrolled into view
interactionon the first hover, focus, touch or key inside the region — an on-demand hole
'(max-width: 600px)'when the media query matches

An on-demand hole is for content that is large, server-rendered, and only needed once the visitor reaches for it: the L3/L4 levels of a mega menu, a filter panel, a details drawer. The fallback is what the page shows meanwhile — for a menu, the L1/L2 levels. The trigger is the first hover, focus, or touch inside the region (pointerover, which fires on hover — before any click — and bubbles up from whatever child the pointer moves over, so it works even though the hole’s wrapper has no box of its own). So the HTML is usually there before the visitor opens anything, and it is morphed in rather than swapped: a menu that is already open stays open, and focus stays where it is.

<script>
  import MegaMenu from './MegaMenu.svelte';
  import MegaMenuFull from './MegaMenuFull.svelte' with { render: 'deferred', wake: 'interaction' };
</script>

<nav>
  <MegaMenuFull>
    {#snippet ogygiaFallback()}<MegaMenu levels={2} />{/snippet}
  </MegaMenuFull>
</nav>

A fallback while it loads

Pass an ogygiaFallback snippet. It shows in the placeholder until the real HTML arrives. One line of setup is necessary: declare the reserved snippet in the props of the server island itself. Then svelte-check accepts it at call sites. The language tools type your original source. Thus no tool can inject the declaration for you:

<script lang="ts">
  import type { Snippet } from 'svelte';
  let { name, ogygiaFallback }: { name: string; ogygiaFallback?: Snippet } = $props();
</script>
<Greeting name="Ada">
  {#snippet ogygiaFallback()}
    <p class="skeleton">Loading…</p>
  {/snippet}
</Greeting>

Caching: dynamic by default

A server island is fresh on each request. Its endpoint is served with Cache-Control: no-store. A reload renders it again. That is the correct default for a hole (a per-request clock, cookie-personal data). To let a hole stay in the browser cache, give it a maxAge in a preset:

ogygia({ regions: { presets: { pricing: { render: 'deferred', wake: 'load', maxAge: '1h' } } } })
<script>
  import Pricing from '$lib/Pricing.svelte' with { preset: 'pricing' };
</script>

Now that hole is served with Cache-Control: private, max-age=3600. The maxAge is signed into the URL of the hole. Thus a harvested capability cannot point at a longer cache.

Adding interactivity: nest an island

A deferred region is content only. It never ships JS. When a part of the fetched HTML must be interactive, put a normal wake island inside the component of the server island. The hole delivers the content. The island inside turns on the buttons:

<!-- the server island (personalized, fetched) -->
<script>
  import CartButtons from './CartButtons.svelte' with { wake: 'load' };
  const cart = await getCart();
</script>

<ul>{#each cart.items as item}<li>{item.name}</li>{/each}</ul>
<CartButtons {cart} />   <!-- the interactive part, its own island -->

That keeps the split clean. render decides how the HTML arrives. wake decides when JS runs. The two live on different components.

Server islands need the server handle. They load through the signed island endpoint.

Reading the request: requestEvent()

A server island renders in its own request. It often needs the visitor: the session in locals, a cookie, the URL. requestEvent() from 'ogygia' returns the RequestEvent of Kit for the render in progress. It needs no import of $app/server, which the client guard of Kit rejects in a component that a csr = true page also renders. It needs no remote function either.

<script lang="ts">
  import { requestEvent } from 'ogygia';
  import type { RequestEvent } from '@sveltejs/kit';

  // call it during setup, BEFORE the first await
  const event = requestEvent<RequestEvent>();
  const user = event?.locals.user ?? null;
  const cart = await getCart(event?.cookies.get('cart_id'));
</script>

Call it during setup, before the first await. It reads Svelte context, which is only available then. In the browser it returns null. The same call works inside a lake and inside an island during the server pass. Note that the request of a hole is not the request of the page: its URL carries no page path. When a hole needs the locale or the id of the page, pass it as a prop.

When the fallback is already right: keepFallback()

Often the ogygiaFallback is the common case. An actions slot shows the login button to everyone who is not logged in; the hole exists for the visitors who are. Rendering the same markup again for every anonymous visitor, and swapping it over itself, is waste. Call keepFallback() from 'ogygia' and the hole ends with no content: the endpoint answers 204, the runtime keeps the fallback the page rendered and marks the region done. No bytes, no swap, no flash. The fallback belongs to the page, not to the server island: a wake island inside it is a full region that wakes on its own, so a kept fallback stays interactive (the login dropdown inside an anonymous actions slot).

<script lang="ts">
  import { requestEvent, keepFallback } from 'ogygia';
  const user = requestEvent()?.locals.user ?? null;
  if (!user) keepFallback(); // the page already shows the anonymous slot
  const account = await getAccount(user);
</script>

<UserMenu {account} />

Call it as soon as you know, before any work the hole would otherwise do. It throws a signal the server handle recognises, so nothing after it runs. In a batch, the hole’s parcel carries a marker instead of HTML; a page served from the freeze store keeps its stored fallback the same way.

Do / don’t

  • Do give a server island an ogygiaFallback. Then the placeholder is not an empty gap while the HTML loads.
  • Do use server islands when a page has several holes. Each hole fills on load. A SPA navigation sends all holes in one request, with no waterfall and no config. See SPA router.
  • Don’t use a server island for interactive UI. That is a client island. For content that is personal and interactive, nest a wake island inside the server island.
  • Do know that render: 'deferred' ships no JS. The fetched HTML is inert until an island inside it wakes.