🍡 mochi

SSR framework for Svelte 5 + Bun with islands-based selective hydration

On this page

Server islands with mochi:defer

Mark a component with mochi:defer to skip it during the initial SSR pass and render it on demand from a dedicated endpoint after the page loads. Use it for personalized fragments such as avatars and cart counts that would otherwise block the surrounding HTML from being cached.

<!-- file: src/Page.svelte -->
<UserAvatar mochi:defer userId={123} />

Children of a deferred component become the fallback shown until the island resolves.

<UserAvatar mochi:defer userId={123}>
  <div class="skeleton">Loading...</div>
</UserAvatar>

Import a deferred component statically from a relative .svelte / .md / .svx path. See Supported import forms.

A server island is a normal Svelte component with full access to the request context through getRequestContext(). Cookies are forwarded automatically because the fetch is same-origin.

<!-- file: src/UserAvatar.svelte -->
<script lang="ts">
  import { getRequestContext } from 'mochi-framework';

  const { cookies } = getRequestContext();
  const userName = cookies.get('user') ?? 'friend';
</script>

<p>Welcome back, {userName}!</p>

How it renders

The page ships with the fallback in place of the island, plus an encrypted token carrying the island’s props. The browser then fetches the rendered HTML from a per-island endpoint under assetPrefix (default /_mochi/island/...), and Mochi swaps it in over the fallback. A failed fetch retries with exponential backoff (default 9 retries, 1s–5s). Pass mochi:defer={{ retries: 10 }} to override.

Combining with hydration

Apply mochi:hydrate alongside mochi:defer to fetch the island on demand and then hydrate it for client-side interactivity.

<ShoppingCart mochi:defer mochi:hydrate items={initialItems} />

Reloading an island with reloadDeferredIsland

Since: 0.10.0 (not released yet): Named defers and reloadDeferredIsland were added in 0.10.0.

Give a defer a name, then re-fetch its server HTML from the browser by calling reloadDeferredIsland(name). Use it to refresh server-rendered content after a mutation without a full page reload. A name must be a non-empty string — anything else warns and leaves the island unnamed.

<Cart mochi:defer={{ name: 'cart' }}>
  <div class="skeleton">Loading...</div>
</Cart>
import { reloadDeferredIsland, reloadDeferredIslandAll } from 'mochi-framework';

await reloadDeferredIsland('cart'); // re-fetches, resolves once swapped in
await reloadDeferredIslandAll(); // reloads every named defer on the page

deferReloadState(name) returns reactive state for that island, for UI that follows along:

<script>
  import { deferReloadState } from 'mochi-framework';

  const cart = deferReloadState('cart');
</script>

<button disabled={cart.reloading} onclick={() => reloadDeferredIsland('cart')}>Refresh</button>
{#if cart.reloading}<Spinner />{/if}
{#if cart.lastReloadOk === false}<p>Last refresh failed.</p>{/if}
<p>
  Refreshed {cart.count} times{#if cart.lastReloaded}, last at {cart.lastReloaded.toLocaleTimeString()}{/if}
</p>
Field
reloadingtrue while that island has a fetch in flight — its first load as well as a reload
countcompleted reload rounds, successful or not
lastReloadOkwhether the last reload round fetched successfully, null before the first
lastReloadedDate the last reload completed, null before the first

Islands sharing a name reload together and settle as one round: count adds one per round, and lastReloadOk is true only when every island in the round fetched successfully. You get one shared instance per name, so reading it repeatedly is free — and reading a field outside a component just gives you its current value.

lastReloadOk reports the fetch, not the render: an island whose component throws still answers with a 200, so it reads true while the island shows its <svelte:boundary> fallback. Render failures are the boundary’s job, not the reload’s.

Both return a promise that settles once every matching island has finished re-fetching. Islands sharing a name reload together, and a mochi:defer mochi:hydrate island unmounts its old component and re-hydrates when the new HTML lands. Reloads on the same island queue behind one another, so a reload issued after a mutation always observes it.

name works on mochi:defer:visible too. A reload fetches immediately regardless of viewport. The viewport trigger queues behind any reload already in flight and stands down once either has delivered content, so the two can never race or double-fetch.

Naming an island also opts it out of nested inlining: a reloadable island needs its own placeholder to fetch into. An explicit inline: true on a named island is ignored, with a warning.

Loading state while reloading

A reloading island keeps showing its current content — the fallback children only ever show before the first load — and carries two attributes for the duration:

<mochi-server-island data-reloading aria-busy="true"></mochi-server-island>

Style data-reloading to mark the wait. Island wrappers are display: contents, so they generate no box of their own — put the styles on the children:

mochi-server-island[data-reloading] > * {
  opacity: 0.6;
}

The old content is swapped out only when the new HTML lands, so hydrated children keep their client state for the whole wait, and a failed fetch leaves the island exactly as it was.

Size the fallback to match the loaded content, or the swap shifts the page. The wrapper is display: contents, so it holds no space of its own while the content is away — the fallback’s own box is the only thing keeping the layout still. Giving both a shared min-height is usually enough:

.card,
.card-skeleton {
  min-height: 3.5rem;
}

Nesting islands inside a server island

A server island’s content is a full render, so it can contain mochi:hydrate islands and further mochi:defer server islands. Hydratable children hydrate once the deferred HTML lands.

<!-- file: src/Dashboard.svelte (rendered via mochi:defer) -->
<Chart mochi:hydrate {data} />
<Notifications mochi:defer />

Mochi inlines nested mochi:defer islands into the parent’s fetch. When the island endpoint renders Dashboard, it renders Notifications in-process too, so one request returns the whole chain no matter how deep it nests. The decision happens at render time. A nested island inside {#if} or {#each} inlines when its branch renders, with the same props the placeholder would seal, so Mochi never renders an unreachable island. If an inlined child throws, it degrades to a fetch placeholder and fetches on its own. The parent’s content stays intact.

Inlining is capped at 32 expansions per island fetch. A recursive chain and a long {#each} list draw from the same budget. Past the cap, children fall back to fetching. Tune the cap per fetch with the serverIsland:inlineBudget filter (see Extensions).

Opt out per call site to keep a child on its own fetch. This helps when a slow child must not delay the parent’s content:

<Notifications mochi:defer={{ inline: false }} />

Opt out globally with inlineNestedIslands:

Mochi.serve({ inlineNestedIslands: false });

mochi:defer:visible children are always exempt. Laziness is their point, so they keep their own viewport-triggered fetch.

Mochi delivers CSS for nested islands with the fetched HTML. The host page cannot link it ahead of time, because the island content does not render until the island resolves. Styles apply as soon as the island appears.

Lazy server islands with mochi:defer:visible

Defer the fetch until the wrapper scrolls into view, mirroring mochi:hydrate:visible.

<UserAvatar mochi:defer:visible={{ rootMargin: '200px' }} userId={123}>
  <div class="skeleton">Loading...</div>
</UserAvatar>

Combine rootMargin and retries: mochi:defer:visible={{ rootMargin: '200px', retries: 10 }}. Combine with mochi:hydrate for interactive lazy islands. Provide fallback children so the user has something to scroll past while waiting.

Props

Mochi serializes props with devalue — see Passing props to islands. Server islands also encrypt the payload and pass it as a query parameter. The island’s component name is bound as authenticated data, so a token sealed for one island cannot be replayed against another.

Encryption key

Mochi encrypts props with a key derived from process.env.MOCHI_KEY (base64url-encoded, any length). Without MOCHI_KEY, Mochi generates a random key and logs a warning — fine for local dev, broken across restarts and multi-instance deploys.

Generate a key and write it to .env:

bunx mochi-framework generate-key

See it in action

Live demos showing key concepts from this page