SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Client-only components with mochi:clientOnly
Add mochi:clientOnly to a component that must never render on the server. SSR emits only an empty island wrapper; in the browser the component is mounted with Svelte’s mount() (not hydrated — there is no SSR HTML to reuse). Use it for components built on browser-only APIs: window, canvas, localStorage, requestAnimationFrame, third-party browser SDKs.
<!-- file: src/Page.svelte -->
<AudioVisualizer mochi:clientOnly />Props work exactly like mochi:hydrate — serialized with devalue and embedded into the HTML. See Passing props to islands for the supported types. The implicit isHydratable prop is injected at mount (always true). There is no islandId prop on a client-only island — nothing renders server-side, so there’s no id to carry; for a unique id inside the component, use Svelte’s $props.id(), which mount() mints fresh in the browser.
<MapWidget mochi:clientOnly zoom={12} center={coords} />Fallback content
Pass fallback markup as children — it renders server-side as placeholder content and is removed the moment the component mounts:
<ChartCanvas mochi:clientOnly data={points}>
<div class="chart-skeleton">Loading chart…</div>
</ChartCanvas>For svelte-check to accept the fallback children, the client-only component types its props with ClientOnlyProps<T> — it adds an optional children snippet so the call site type-checks without you declaring a children prop by hand:
<script lang="ts">
import type { ClientOnlyProps } from 'mochi-framework';
let { data }: ClientOnlyProps<{ data: number[] }> = $props();
</script>Lazy client-only with mochi:clientOnly:visible
Defer the browser-side mount() until the wrapper scrolls into the viewport. The component still never renders on the server — its JavaScript and CSS are simply fetched and mounted only when the placeholder intersects the viewport via IntersectionObserver.
<AudioVisualizer mochi:clientOnly:visible />Pass rootMargin to start loading before the element enters the viewport — forwarded straight to IntersectionObserver (default '0px'):
<AudioVisualizer mochi:clientOnly:visible={{ rootMargin: '200px' }} />Provide fallback children as the placeholder — they reserve space and give the observer something to watch until the component mounts (an empty wrapper still gets a 1px minimum height so it remains observable):
<ChartCanvas mochi:clientOnly:visible data={points}>
<div class="chart-skeleton">Loading chart…</div>
</ChartCanvas>Server-side APIs are unavailable
The component never runs on the server, so server-only APIs — getRequestContext(), cookies, hydratable() server reads — are unavailable inside it. Pass any server-derived values in as props from the page.
Limitations
- Combining
mochi:clientOnly*withmochi:hydrate*ormochi:defer*is a compile error — a client-only component is never server-rendered. - Like other islands, it must not be nested inside another hydratable component.