SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Selective hydration with mochi:hydrate
Components render on the server and ship zero JavaScript. Add mochi:hydrate to opt a component into client-side hydration. Everything else stays static HTML.
<!-- file: src/routes/Page.svelte -->
<Counter mochi:hydrate count={5} />
<StaticHeader />Mochi serializes props with devalue so the same values are available during hydration. See Passing props to islands for the supported types.
What is an island?
An island is any component you mark with a mochi:* directive: mochi:hydrate, mochi:hydrate:visible, mochi:clientOnly, mochi:clientOnly:visible, mochi:defer, or mochi:defer:visible. Everything else is server-rendered HTML that ships no JavaScript. The directive decides when and where the island runs.
Supported import forms
Import an island statically from a relative .svelte / .md / .svx path in the same file’s <script>. Default, named, and mixed imports all work.
<script>
import Counter from './Counter.svelte'; // default
import { Widget } from './Barrel.svelte'; // named (module-script export)
import Chart, { presets } from './Chart.svelte'; // mixed
</script>
<Counter mochi:hydrate />
<Widget mochi:hydrate />
<Chart mochi:hydrate:visible />Framework components from mochi-framework/components are the one package exception. Put a directive directly on the package import.
<script>
import { MochiCaptcha } from 'mochi-framework/components';
</script>
<MochiCaptcha mochi:hydrate />Any other import form is a compile error, surfaced on the dev error page and in mochi-framework build. The two you are most likely to hit:
- Third-party package imports (
import { Widget } from 'some-ui-lib'). Wrap the component in a local.sveltefile and put the directive on the wrapper. - Components received through props, variables, or namespaces (
<Item.Row mochi:hydrate />). An island needs a statically known source file. Use the same wrapper fix.
<!-- file: src/lib/Wrapped.svelte — local wrapper makes a third-party component an island -->
<script>
import { Widget } from 'some-ui-lib';
let props = $props();
</script>
<Widget {...props} />These rules apply to every directive family: mochi:hydrate*, mochi:defer*, and mochi:clientOnly*.
No children on hydrate islands
Since: 0.10.0 (not released yet): Children on mochi:hydrate* became a compile error in 0.10.0; they previously rendered server-side and silently vanished on hydration.
A mochi:hydrate / mochi:hydrate:visible island cannot take children at the call site — the client hydrates from the serialized props alone, and a snippet cannot cross the server→client boundary:
<Wrapper mochi:hydrate>
<p>This is a compile error.</p>
</Wrapper>Move the markup inside the component, or pass what it needs as serializable props. A layout-style wrapper that renders {@render children()} therefore cannot be a hydrate island — mark the interactive components inside it instead. With mochi:defer* and mochi:clientOnly*, children are legal and mean something different: the loading fallback. See Server islands and mochi:clientOnly.
isHydratable()
isHydratable() returns true when the calling component belongs to a subtree that will hydrate on this page load — mochi:hydrate*, mochi:clientOnly*, or mochi:defer mochi:hydrate — at any nesting depth. It returns false everywhere else (plain SSR, pure mochi:defer renders, emails). Use it to branch SSR-only fallback behavior.
<!-- file: src/lib/Counter.svelte -->
<script lang="ts">
import { isHydratable } from 'mochi-framework';
let { count = 0 }: { count?: number } = $props();
const hydratable = isHydratable();
</script>
{#if hydratable}
<button onclick={() => count++}>{count}</button>
{:else}
<span>{count}</span>
{/if}Unique ids with $props.id()
For an SSR-stable id inside an island, use Svelte’s native $props.id(). The value from the server render is reused on hydration.
<!-- file: src/lib/SignupField.svelte -->
<script lang="ts">
const uid = $props.id();
</script>
<label for="{uid}-email">Email</label>
<input id="{uid}-email" type="email" />Each instance gets its own id, so repeating the same island never produces duplicate DOM ids. It also works inside server islands: their ids are namespaced so a deferred fragment cannot collide with ids already on the page.
mochi:hydrate:visible
Use mochi:hydrate:visible to defer hydration until the component scrolls into view. The component still server-renders. Only its JavaScript and CSS load on first intersection.
<HeavyChart mochi:hydrate:visible />
<HeavyChart mochi:hydrate:visible={{ rootMargin: '200px' }} />Pass rootMargin to start loading before the component enters the viewport. See Lazy hydration.
mochi:clientOnly
Use mochi:clientOnly to skip SSR entirely. Mochi mounts the component in the browser only, with an optional fallback snippet as the SSR placeholder. See Client-only components.
<AudioVisualizer mochi:clientOnly />Add :visible to defer the browser mount until the placeholder scrolls into view, with the same rootMargin option.
<AudioVisualizer mochi:clientOnly:visible={{ rootMargin: '200px' }} />mochi:defer
Use mochi:defer to render the component in a separate request after the page ships. Combine it with mochi:hydrate to also hydrate the deferred markup. See Server islands.
<ShoppingCart mochi:defer mochi:hydrate items={initialItems} />Add :visible to defer the fetch until the placeholder scrolls into view.
<UserAvatar mochi:defer:visible={{ rootMargin: '200px' }} userId={123} />See it in action
Live demos showing key concepts from this page