Skip to content

Regions

Server islands

HTML fetched from the server after the page loads. The page shows a fallback first and swaps in the real HTML, with no JavaScript needed.

On this page

Some parts of a page depend on the request but don’t need to be interactive: a personal greeting, a “you have 3 unread” count, or a panel backed by a slow upstream that shouldn’t hold up the first paint. Rendering them in the page’s own pass would make the whole page personal and as slow as its slowest part. A server island leaves a hole in the page instead. The page ships with a placeholder, and the real HTML comes from the server after the first load and swaps in on a schedule. Nothing hydrates it, because it is server HTML that arrives late, so it costs less than a client island.

You make one by marking the import with render: 'deferred':

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

<Greeting name="Ada" />

Greeting renders on the server for each request, through a signed endpoint, and its HTML fills the hole. If part of a server island has to be interactive, you nest a client island inside it, as described below. If the content can be prerendered, it doesn’t need to be deferred at all.

The demo below is a real server island on this page. Its timestamp is set when the island’s HTML arrives, after the page loads, rather than when the page rendered, which shows that the HTML came from the server later.

Loading from the server…

To watch the round trip, click Fetch again. The hole returns to its fallback, fetches fresh server HTML and swaps it in. The timestamp changes every time, because a deferred hole is dynamic by default.

/demo/deferred · server island (a hole)

When it fetches: the fetch schedule

A deferred region fetches on its fetch schedule, which uses the same timing words as an island’s wake (load, idle, visible, interaction and a media query; Timing & nesting defines each one). render: 'deferred' on its own fetches on load. Adding fetch makes the fetch itself later, so a hole below the fold costs no request until you scroll to it:

<script>
  import Chart from './Chart.svelte' with { render: 'deferred', fetch: 'visible' };
</script>

fetch decides when the HTML arrives, and wake decides when an island’s JavaScript runs. A hole has no JavaScript, so it takes fetch and not wake. The old pairing of render: 'deferred' with wake fails the build, with a message that names the fetch to write instead.

With fetch: 'interaction' the hole is on demand. This suits content that is large, rendered on the server and only needed once the visitor reaches for it, such as the third and fourth levels of a mega menu, a filter panel or a details drawer. The fallback is what the page shows in the meantime, which for a menu would be its first two levels. The trigger is the first hover, focus or touch inside the region. Hover is detected with pointerover, which fires before any click and bubbles up from whichever child the pointer moves over, so it works even though the hole’s wrapper has no box of its own. As a result the HTML is usually there before the visitor opens anything, and it is morphed in rather than swapped, so 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', fetch: 'interaction' };
</script>

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

A fallback while it loads

Pass an ogygiaFallback snippet, and it shows in the placeholder until the real HTML arrives. You need one line of setup for this: declare the reserved snippet in the server island’s own props, so that svelte-check accepts it at call sites. The language tools type your original source, so no tool can inject that 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 every request. Its endpoint is served with Cache-Control: no-store, and a reload renders it again, which is the right default for a hole that shows a per-request clock or data personal to a cookie. To let a hole stay in the browser cache, give it a maxAge in a preset:

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

That hole is now served with Cache-Control: private, max-age=3600. The maxAge is signed into the hole’s URL, so a harvested capability cannot be pointed at a longer cache.

Adding interactivity: nest an island

A deferred region is content only and never ships JavaScript. When part of the fetched HTML has to be interactive, put an ordinary wake island inside the server island’s component. The hole delivers the content, and the island inside makes the buttons work:

<!-- 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 -->

This keeps the responsibilities apart: render and fetch decide how and when the HTML arrives, wake decides when JavaScript runs, and the two sit on different components.

Server islands load through the signed island endpoint, so they need the server handle.

Reading the request: requestEvent()

A server island renders in its own request, and it often needs to know about the visitor: the session in locals, a cookie or the URL. requestEvent() from 'ogygia' returns Kit’s RequestEvent for the render in progress. It doesn’t need an import of $app/server, which Kit’s client guard rejects in a component that a csr = true page also renders, and it doesn’t need a 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, because 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. Keep in mind that a hole’s request is not the page’s request: requestEvent() describes the hole’s own call. The page it is mounted on still reaches its components, though, because page.url, page.route, page.params and page.data read the calling page (see A lake that can refresh).

When the fallback is already right: keepFallback()

Often the ogygiaFallback is the common case. An actions slot might show the login button to everyone who isn’t logged in, so the hole only exists for the visitors who are. Rendering the same markup again for every anonymous visitor, and swapping it over itself, is wasted work. If you call keepFallback() from 'ogygia', the hole ends with no content: the endpoint answers 204, and the runtime keeps the fallback the page rendered and marks the region done, so there are no bytes, no swap and no flash. The fallback belongs to the page rather than to the server island, and a wake island inside it is a full region that wakes on its own, so a kept fallback stays interactive (for example, 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 that the server handle recognises, so nothing after it runs. In a batch, the hole’s parcel carries a marker instead of HTML, and a page served from the postrender store keeps its stored fallback the same way.

Do / don’t

  • Do give a server island an ogygiaFallback, so the placeholder isn’t an empty gap while the HTML loads.
  • Do use server islands freely when a page has several holes. Each hole fills on load, and an SPA navigation fetches all of them in one request, with no waterfall and no configuration. See SPA router.
  • Don’t use a server island for interactive UI; that is a client island. For content that is both personal and interactive, nest a wake island inside the server island.
  • Do remember that render: 'deferred' ships no JavaScript, so the fetched HTML stays inert until an island inside it wakes.