🍡 mochi

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

On this page

Coming from SvelteKit

This page maps SvelteKit features to their Mochi equivalents so you can start quickly. Read the feature table first, or skip to the feature list.

Feature comparison

FeatureMochiSvelteKit
Server islands & selective hydration Yes - mochi:defer No - full page hydration only
Built-in SQLite database Yes - bun:sqlite Partial - node:sqlite via adapter-node on Node or Deno
Built-in Postgres & MySQL support Yes - Bun.sql() No - bring your own cloud database
Background job queues Yes - Mochi.queue() No
Minimal client-side JavaScript Yes - zero JS unless hydrated; tuned for first load Partial - tuned for repeat navigations; no zero-JS pages (CSR opt-out breaks interactivity)
Deployment targets No - Bun only Yes - Node, Vercel, Bun and other cloud providers
Prerendering / SSG Partial - route warmup supported Yes
Build as static HTML No - SSR only Yes - adapter-static
Form actions + progressively enhanced forms Yes Yes
Form captcha Yes - MochiCaptcha No - third-party service
Rate limiting Yes No - third-party package only
Middleware Yes Yes
Hooks & extension filters Yes - eventHooks & filters No
Top-level await Yes Partial - experimental
Real-time WebSockets Yes - Mochi.ws() No - custom server with external package
Server-Sent Events Yes - Mochi.sse() No - manual setup, limited provider support
Built-in caching library Yes - MochiCache No
Cookie helpers Yes Yes
AI development support Yes - Skill, MCP & llms.txt Yes - Skill, MCP & llms.txt
Client-side router No Yes
Type-safe routes & params Planned Yes - generated ./$types & $app/types
Remote functions (type-safe RPC) No Yes - experimental
View Transitions Yes - built-in component Partial - manual wiring
Highly interactive apps Yes - WS, SSE & View Transitions Yes - SPA mode & remote functions
Tailwind Yes - Tailwind v4 Yes
Built-in Markdown (mdsvex) Yes - mdsvex built-in Yes - via integration (sv add mdsvex)
Centralized logging system Yes - mochiEvents No - experimental OpenTelemetry only
Image resizing Yes - named sizes, runtime transforms Partial - build-time only; runtime at extra cost
Link preloading Planned Yes
Service worker integration Planned Yes
Snapshots Partial - browser-native restoration Yes - manual setup

Routing

SvelteKit uses a file-based router. Mochi uses a programmatic routes record passed to Mochi.serve({ routes }). Each key is a Bun router pattern. Each value is built with Mochi.page, Mochi.api, Mochi.ws, or Mochi.sse.

// SvelteKit
src/routes/+page.svelte              → /
src/routes/posts/[slug]/+page.svelte → /posts/:slug
src/routes/health/+server.ts         → /health
// file (Mochi): src/index.ts
import { Mochi } from 'mochi-framework';

await Mochi.serve({
  routes: {
    '/': Mochi.page('./src/Home.svelte'),
    '/posts/:slug': Mochi.page('./src/Post.svelte'),
    '/health': Mochi.api(() => Response.json({ status: 'ok' })),
  },
});

Advanced routing

Mochi uses Bun router patterns: :slug for a required parameter, * for a catch-all. There is no SvelteKit-style [[optional]] segment, no [param=matcher] syntax, and no src/params/ directory. Validate a parameter’s shape inline.

// file (SvelteKit): src/params/fruit.ts
export function match(param: string): param is 'apple' | 'orange' {
  return param === 'apple' || param === 'orange';
}
// then: src/routes/fruits/[name=fruit]/+page.svelte
// file (Mochi): src/index.ts
import { Mochi, error } from 'mochi-framework';

await Mochi.serve({
  routes: {
    '/fruits/:name': Mochi.page('./src/Fruit.svelte', {
      serverProps: (_req, params) => {
        if (params.name !== 'apple' && params.name !== 'orange') error(404, 'Unknown fruit');
        return { name: params.name };
      },
    }),
    '/files/*': Mochi.page('./src/Files.svelte'),
  },
});

A :param always captures the whole segment — you can’t put literal text beside it. SvelteKit’s profile/@[user] has no direct equivalent: Bun treats @:user as literal text, so /profile/@:user never matches. Match /profile/:user instead — the param captures the whole segment, sigil included (params.user === '@bob') — and note it also matches /profile/bob, silently serving the page at two URLs unless you guard:

// file (Mochi): the SvelteKit route profile/@[user]
'/profile/:user': Mochi.page('./src/Profile.svelte', {
  serverProps: (_req, params) => {
    if (!params.user.startsWith('@')) error(404); // /profile/bob must not alias /profile/@bob
    return { username: params.user.slice(1) };
  },
}),

Layouts

SvelteKit’s +layout.svelte / +layout.server.ts have no Mochi equivalent. In SvelteKit, layouts persist across navigations: the client-side router keeps the layout component mounted and only swaps the page slot. That also lets +layout.server.ts skip refetching data that is still valid. Mochi has no client-side router, so every navigation is a full page load. There is no component tree to persist and no data to revalidate selectively.

Instead, create a wrapper component that accepts children, then import it from each page.

<!-- file (SvelteKit): src/routes/+layout.svelte -->
<script>
  let { data, children } = $props();
</script>

<nav>Hi {data.user.name}</nav>
{@render children()}
<!-- file (Mochi): src/lib/Layout.svelte -->
<script>
  let { user, children } = $props();
</script>

<nav>Hi {user.name}</nav>
{@render children()}

There is nothing special about the layout — it is a plain component. SvelteKit applies it for you; in Mochi you import it into the page and wrap the markup yourself:

<!-- file (Mochi): src/Home.svelte -->
<script>
  import Layout from './lib/Layout.svelte';
  let { user, posts } = $props();
</script>

<Layout {user}>
  <h1>Posts</h1>
  {#each posts as post}
    <a href="/posts/{post.slug}">{post.title}</a>
  {/each}
</Layout>

Share the data that SvelteKit’s +layout.server.ts would have loaded through a common helper, then spread the result into each route’s serverProps:

// file (SvelteKit): src/routes/+layout.server.ts
export async function load() {
  return { user: await loadCurrentUser() };
}
// file (Mochi): src/lib/baseProps.ts
export async function baseProps() {
  return { user: await loadCurrentUser() };
}
// file (Mochi): src/index.ts
import { Mochi } from 'mochi-framework';
import { baseProps } from './lib/baseProps';

await Mochi.serve({
  routes: {
    '/': Mochi.page('./src/Home.svelte', {
      serverProps: async () => ({ ...(await baseProps()), posts: await loadPosts() }),
    }),
  },
});

Load functions

SvelteKit’s load becomes serverProps on your Mochi.page call. It is a plain object or a (req, params) => props resolver (sync or async). The result reaches the component as $props.

// file (SvelteKit): src/routes/posts/[slug]/+page.server.ts
export async function load({ params }) {
  return { post: await loadPost(params.slug) };
}
// file (Mochi): src/index.ts
import { Mochi } from 'mochi-framework';

await Mochi.serve({
  routes: {
    '/posts/:slug': Mochi.page('./src/Post.svelte', {
      serverProps: async (_req, params) => ({ post: await loadPost(params.slug) }),
    }),
  },
});
<!-- file (Mochi): src/Post.svelte -->
<script>
  let { post } = $props();
</script>

<h1>{post.title}</h1>

The Mochi.page() entry component renders on the server, so you can also call data helpers directly inside the component instead of threading every value through props. Client-side interactivity is opt-in per child component with mochi:hydrate, mochi:hydrate:visible, mochi:defer, or mochi:defer:visible.

You can read getRequestContext().params anywhere on the server instead of threading params through serverProps.

A guard redirect inside load ports directly: return redirect(status, location) from serverProps and the page render is skipped (see Redirecting from serverProps; requires mochi-framework 0.10.0).

serverProps: (req) => {
  if (!currentUser(req)) return redirect(303, '/login');
  return { settings: loadSettings() };
},

Form actions

SvelteKit’s actions export becomes the actions field on Mochi.page. The helpers fail, redirect, and success import from mochi-framework. A POST matches an ?/<name> query (or default when absent). The action’s return value populates a form prop on re-render. The action callback receives { request, url, server, locals, kind, method, formData, actionName, cookies, params }.

// file (SvelteKit): src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const username = String(formData.get('username') ?? '');
    if (!username) return fail(400, { error: 'Username required' });
    return { username };
  },
  logout: () => redirect(303, '/'),
};
// file (Mochi): src/index.ts
import { Mochi, fail, success, redirect } from 'mochi-framework';

await Mochi.serve({
  routes: {
    '/login': Mochi.page('./src/Login.svelte', {
      actions: {
        default: ({ formData, cookies }) => {
          const username = String(formData.get('username') ?? '');
          if (!username) return fail(400, { error: 'Username required' });
          cookies.set('user', username, { httpOnly: true, path: '/' });
          return success({ username });
        },
        logout: () => redirect(303, '/'),
      },
    }),
  },
});
<!-- file (Mochi): src/Login.svelte -->
<script>
  let { form } = $props();
</script>

{#if form?.data?.error}<p>{form.data.error}</p>{/if}

use:enhance

SvelteKit’s use:enhance becomes Mochi’s enhance attachment from mochi-framework. The wire format and submit callback semantics match closely — MochiEnhanceResult mirrors SvelteKit’s ActionResult.

<!-- file (SvelteKit): src/routes/login/+page.svelte -->
<script>
  import { enhance } from '$app/forms';
</script>

<form method="POST" action="?/login" use:enhance>
  <input name="username" />
  <button type="submit">Log in</button>
</form>
<!-- file (Mochi): src/Login.svelte -->
<script>
  import { enhance } from 'mochi-framework';
</script>

<form method="POST" action="?/login" {@attach enhance()}>
  <input name="username" />
  <button type="submit">Log in</button>
</form>

API routes (+server.ts)

SvelteKit’s +server.ts with GET / POST exports becomes Mochi.api(handler). One handler per route. Branch on method inside. The handler receives { request, url, server, locals, kind, method, params, cookies }.

// file (SvelteKit): src/routes/api/users/[id]/+server.ts
export async function GET({ params }) {
  return Response.json(await loadUser(params.id));
}

export async function POST({ params, request }) {
  return Response.json(await createUser(params.id, await request.json()));
}
// file (Mochi): src/index.ts
import { Mochi, error } from 'mochi-framework';

await Mochi.serve({
  routes: {
    '/api/users/:id': Mochi.api(async ({ method, request, params }) => {
      if (method === 'GET') return Response.json(await loadUser(params.id));
      if (method === 'POST') return Response.json(await createUser(params.id, await request.json()));
      error(405, 'Method not allowed');
    }),
  },
});

error(), redirect(), fail()

Same names, imported from mochi-framework. error(status, message?) throws MochiHttpError, defaulting the message to the canonical status text when omitted. redirect(status, location) returns from an action or a serverProps resolver. fail(status, data) and success(data?) round-trip through the form prop (under form.data) or the enhance envelope.

// SvelteKit
import { error, redirect, fail } from '@sveltejs/kit';
// Mochi
import { error, redirect, fail, success } from 'mochi-framework';

Error pages (+error.svelte)

SvelteKit’s +error.svelte becomes the errorPage option on Mochi.serve() — one component for the whole app, defaulting to DefaultError.svelte. It receives one error: MochiErrorProps prop with status, message, and stack (dev only).

<!-- file (SvelteKit): src/routes/+error.svelte -->
<script>
  import { page } from '$app/state';
</script>

<h1>{page.status}</h1><p>{page.error.message}</p>
// file (Mochi): src/index.ts
await Mochi.serve({ errorPage: './src/Error.svelte', routes });
<!-- file (Mochi): src/Error.svelte -->
<script lang="ts">
  import type { MochiErrorProps } from 'mochi-framework';
  let { error }: MochiErrorProps = $props();
</script>

<h1>{error.status}</h1><p>{error.message}</p>

See Error handling.

Hooks (handle)

SvelteKit’s hooks.server.ts handle export becomes the handle option on Mochi.serve(). The shape matches: async ({ event, resolve }) => Response. event carries { request, url, server, locals, kind }. Compose handles with sequence(...). Unlike SvelteKit, event itself does not carry cookies, params, or getClientAddress — read those from getRequestContext() inside the handler.

// file (SvelteKit): src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.user = await loadUser(event.request);
  return resolve(event);
};
// file (Mochi): src/handle.ts
import type { Handle } from 'mochi-framework';

export const auth: Handle = async ({ event, resolve }) => {
  if (event.kind === 'asset') return resolve(event);
  event.locals.user = await loadUser(event.request);
  return resolve(event);
};
// file (Mochi): src/index.ts
import { Mochi, sequence } from 'mochi-framework';
import { auth } from './handle';

await Mochi.serve({ handle: sequence(auth), routes });

resolve options

SvelteKit’s resolve(event, { transformPageChunk, filterSerializedResponseHeaders }) maps to Mochi’s resolve(event, { transformPage, filterResponseHeaders }). transformPage({ html, done }) rewrites the HTML body. filterResponseHeaders(name, value) keeps or drops a header.

// file (SvelteKit): src/hooks.server.ts
export const handle = ({ event, resolve }) =>
  resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%THEME%', 'dark'),
    filterSerializedResponseHeaders: (name) => name.toLowerCase() !== 'server',
  });
// file (Mochi): src/handle.ts
import type { Handle } from 'mochi-framework';

export const stripServer: Handle = ({ event, resolve }) =>
  resolve(event, {
    transformPage: ({ html }) => html.replace('%THEME%', 'dark'),
    filterResponseHeaders: (name) => name.toLowerCase() !== 'server',
  });

handleError

Same name. Configure it as a Mochi.serve() option. The hook receives { error, event, status, message } and may return { status, message }, a Response, or void.

// file (SvelteKit): src/hooks.server.ts
import type { HandleServerError } from '@sveltejs/kit';

export const handleError: HandleServerError = ({ error, event }) => {
  tracker.capture(error, { path: event.url.pathname });
  return { message: 'Internal error' };
};
// file (Mochi): src/index.ts
import type { HandleError } from 'mochi-framework';

const handleError: HandleError = ({ error, event }) => {
  if (error) tracker.capture(error, { path: event.url.pathname });
};

await Mochi.serve({ handleError, routes });

event.locals

Same surface. Set event.locals.x from middleware. Read it from any server-side code with getRequestContext().locals.

// file (SvelteKit): src/routes/profile/+page.server.ts
export const load = ({ locals }) => ({ user: locals.user });
// file (Mochi): src/SomePage.svelte
import { getRequestContext } from 'mochi-framework';
const { locals } = getRequestContext();

Observability

SvelteKit’s experimental OpenTelemetry tracing has no direct equivalent. Mochi emits structured lifecycle events on mochiEventsrequest, ws:*, sse:*, cache:*, action:*, server:start, server:stop, and compile-time events. Subscribe to feed tracing, metrics, or a custom logger.

// file (SvelteKit): svelte.config.js
export default {
  kit: { experimental: { tracing: { server: true }, instrumentation: { server: true } } },
};
// then write OTel setup in src/instrumentation.server.ts
// file (Mochi): src/index.ts
import { mochiEvents } from 'mochi-framework';

mochiEvents.on('request', ({ method, path, status, duration }) => {
  metrics.timing('http.request', duration, { method, path, status });
});

consoleLogger() is the default subscriber for human-readable output. See Events.

Client IP (getClientAddress)

SvelteKit’s event.getClientAddress() becomes a method on getRequestContext(). Configure proxy.addressHeader / proxy.xffDepth on Mochi.serve() for trusted reverse-proxy hops.

// file (SvelteKit): src/routes/api/whoami/+server.ts
export const GET = ({ getClientAddress }) => new Response(getClientAddress());
// file (Mochi): src/handle.ts
import { getRequestContext } from 'mochi-framework';
const ip = getRequestContext().getClientAddress();

CSRF protection

Like SvelteKit, Mochi rejects cross-origin form POSTs by default. It checks the Origin header on POST / PUT / PATCH / DELETE when the body is a form content type. Configure it with the csrf option on Mochi.serve() and the csrf:trustedOrigins, csrf:protectedMethods, csrf:formContentTypes, and csrf:check filters.

// file (SvelteKit): svelte.config.js
export default {
  kit: { csrf: { checkOrigin: true } }, // default
};
// file (Mochi): src/index.ts
await Mochi.serve({
  csrf: { trustedOrigins: ['https://embed.example'] },
  routes,
});

Cookies

event.cookies becomes getRequestContext().cookies (also on the form-action callback as event.cookies). Same get / set / delete API and CookieSerializeOptions. App-wide defaults live on the cookie:defaults filter rather than per call.

// file (SvelteKit): src/routes/login/+page.server.ts
export const actions = {
  default: ({ cookies }) => {
    cookies.set('session', token, { httpOnly: true, sameSite: 'lax', path: '/' });
  },
};
// file (Mochi): src/handle.ts
import { getRequestContext } from 'mochi-framework';

const { cookies } = getRequestContext();
cookies.set('session', token, { httpOnly: true, sameSite: 'Lax', path: '/' });

$app/state and the page store

There is no reactive page store. Import url, params, cookies, and locals directly from mochi-framework. url is isomorphic — it reads the request context on the server and window.location on the client. params and locals are server-only.

<!-- file (SvelteKit): src/routes/+page.svelte -->
<script>
  import { page } from '$app/state';
</script>

<p>{page.url.pathname} — {page.params.slug}</p>
<!-- file (Mochi): src/Some.svelte -->
<script>
  import { url, params } from 'mochi-framework';
</script>

<p>{url.pathname} — {params.slug}</p>

$app/navigation (goto, invalidate, preloadData)

No equivalent. Mochi has no client-side router, so there is nothing to goto into and nothing to invalidate. Every navigation is a full HTML round-trip. For form submissions, use enhance(). For anything else, set window.location.href or call history.pushState from a hydrated island.

<!-- file (SvelteKit): src/routes/+page.svelte -->
<script>
  import { goto, invalidateAll } from '$app/navigation';
</script>

<button onclick={() => goto('/dashboard')}>Go</button>
<button onclick={() => invalidateAll()}>Refresh</button>
<!-- file (Mochi): src/SomeIsland.svelte -->
<button onclick={() => (window.location.href = '/dashboard')}>Go</button>

Planned, but not yet available. Mochi has no client router today, so there is nothing to preload code or data into. The browser handles <a> clicks natively, and link preloading is on the roadmap. data-sveltekit-reload, data-sveltekit-replacestate, data-sveltekit-keepfocus, and data-sveltekit-noscroll likewise have no Mochi attribute.

<!-- SvelteKit -->
<body data-sveltekit-preload-data="hover">
  <a href="/about">About</a>
</body>
<!-- Mochi -->
<a href="/about">About</a>

Snapshots

No equivalent. SvelteKit’s snapshot exists because its client router reuses page components across navigation. Mochi does full page reloads, so the browser’s bfcache restores native <input> / <textarea> / scroll state on Back / Forward when the page is bfcache-eligible (no Cache-Control: no-store, no unfinished requests). For component state, or anything that must survive forward navigation or a hard reload, write to sessionStorage from a hydrated island, or use a cookie or query parameter.

<!-- file (SvelteKit): src/routes/comment/+page.svelte -->
<script>
  let comment = $state('');
  export const snapshot = {
    capture: () => comment,
    restore: (v) => (comment = v),
  };
</script>

<textarea bind:value={comment}></textarea>
<!-- file (Mochi): src/Comment.svelte (hydrated island) -->
<script>
  let comment = $state(sessionStorage.getItem('comment') ?? '');
  $effect(() => sessionStorage.setItem('comment', comment));
</script>

<textarea bind:value={comment}></textarea>

Shallow routing (pushState / replaceState)

No framework helper. Call history.pushState / history.replaceState directly from a hydrated island. There is no page.state to read back, so store the value alongside the URL or in component state.

<!-- file (SvelteKit): src/routes/photos/+page.svelte -->
<script>
  import { pushState } from '$app/navigation';
  import { page } from '$app/state';
</script>

<button onclick={() => pushState('', { modal: 'open' })}>Open</button>
{#if page.state.modal}<Modal />{/if}
<!-- file (Mochi): src/PhotoGrid.svelte -->
<button onclick={() => history.pushState({ modal: 'open' }, '', location.href)}>Open</button>

Service workers

Planned, but not yet available. There is no src/service-worker.ts convention and no $service-worker virtual module. Built-in service worker integration is on the roadmap. Register one yourself from a hydrated island if you need offline support today.

// file (SvelteKit): src/service-worker.ts
import { build, files, version } from '$service-worker';
const ASSETS = `cache-${version}`;
// install / fetch handlers …
// file (Mochi): src/Boot.svelte (hydrated island)
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}
// then ship /public/sw.js yourself

Image optimization (@sveltejs/enhanced-img)

Supported, with a different split of the work. SvelteKit optimizes at build time through @sveltejs/enhanced-img, with runtime transformations available at extra cost through a CDN. Mochi imports local images the same Vite-style way — the import returns { src, width, height, format } and copies the file to a content-hashed URL. Declare the transforms once as named sizes on Mochi.serve(). They run on demand in Bun.Image behind an encrypted URL, cached to disk with stale-while-revalidate. The same <Image> works for remote sources, and placeholder adds a ThumbHash blur-up with no client JavaScript.

<!-- file (SvelteKit): src/routes/+page.svelte -->
<script>
  import hero from './hero.png?enhanced';
</script>

<enhanced:img src={hero} alt="Hero" />
<!-- file (Mochi): src/Home.svelte — `hero` size declared in Mochi.serve({ image: { sizes } }) -->
<script>
  import { Image } from 'mochi-framework/image';
  import hero from './hero.png';
</script>

<Image src={hero} size="hero" alt="Hero" />

Remote functions

No equivalent. SvelteKit’s type-safe query / command RPC has no built-in counterpart. Use Mochi.api(handler) with a hand-rolled fetch(), or Mochi.page actions with enhance() for form-driven mutations.

// file (SvelteKit): src/routes/likes.remote.ts
import * as v from 'valibot';
import { command } from '$app/server';

export const addLike = command(v.string(), async (id) => {
  await db.sql`UPDATE item SET likes = likes + 1 WHERE id = ${id}`;
});
// file (Mochi): src/index.ts
import { Mochi, error } from 'mochi-framework';

await Mochi.serve({
  routes: {
    '/api/like/:id': Mochi.api(async ({ method, params }) => {
      if (method !== 'POST') error(405, 'POST only');
      await db.sql`UPDATE item SET likes = likes + 1 WHERE id = ${params.id}`;
      return Response.json({ ok: true });
    }),
  },
});
// on the client: await fetch(`/api/like/${id}`, { method: 'POST' })

$env/static/* and $env/dynamic/*

None of these virtual modules exist. Bun auto-loads .env, so read everything through process.env.FOO.

What $env/static/private really bought you was a build-time error when a private value reached a client-reachable module. In Mochi that job belongs to .server.ts files, which are replaced with a throwing stub in the client build. Mochi’s environment constants (isServer, isBrowser, isDev) branch on render target, but they do not keep the untaken branch out of the bundle — reach for them to pick a code path, not to hide a secret.

// SvelteKit
import { API_KEY } from '$env/static/private';
import { PUBLIC_API_URL } from '$env/static/public';
// Mochi
const apiKey = process.env.API_KEY;
const publicApiUrl = process.env.PUBLIC_API_URL;

$app/paths (asset, base, resolve)

No equivalent. Mochi does not support sub-path deployments through configuration. Write your app links as absolute paths (/about). The assetPrefix option on Mochi.serve() rewrites the URL prefix for framework-internal bundles only (default /_mochi), not for your own routes or static files.

<!-- SvelteKit -->
<script>
  import { asset, base } from '$app/paths';
</script>

<a href="{base}/about">About</a>
<img src={asset('/logo.png')} />
<!-- Mochi -->
<a href="/about">About</a>
<img src="/logo.png" />

Server-only modules

SvelteKit’s .server.ts suffix carries over — Mochi uses the same convention. There is no $lib/server directory equivalent. The suffix is the entire mechanism, applied per file anywhere in your source tree. Every named and default export of a *.server.ts file is replaced with a throwing Proxy on the client, so the real module body compiles for SSR only.

// file (SvelteKit): src/lib/server/db.ts
import { Database } from 'better-sqlite3';
export const db = new Database(':memory:');
// file (Mochi): src/lib/db.server.ts
import { Database } from 'bun:sqlite';
export const db = new Database(':memory:');
<!-- file (Mochi): src/FactCard.svelte — hydratable island -->
<script>
  import { hydratable } from 'svelte';
  import { db } from './lib/db.server.ts';

  const version = await hydratable('app:sqlite-version', () => db.query('SELECT sqlite_version() as v').get().v);
</script>

<p>SQLite {version}</p>

$lib

No virtual $lib alias. Add the path to tsconfig.json if you want the same ergonomic.

<!-- SvelteKit (works out of the box) -->
<script>
  import Button from '$lib/Button.svelte';
</script>
// file (Mochi): tsconfig.json
{
  "compilerOptions": {
    "paths": { "$lib/*": ["./src/lib/*"] }
  }
}

Page options (ssr, csr, prerender)

Not configurable per page. Mochi always renders on the server. Client-side JavaScript is opt-in per component with mochi:hydrate, mochi:hydrate:visible, mochi:defer, or mochi:defer:visible. There is no prerender / SSG mode — every request renders fresh.

Trailing-slash policy is global, not per page. Set trailingSlash: 'never' | 'always' on Mochi.serve() and Mochi registers both forms of every page route, then redirects to the canonical one. Only page routes follow it — Mochi.api(), Mochi.sse(), Mochi.ws() and Mochi.file() match the pattern you declared. See Trailing slash.

// file (SvelteKit): src/routes/about/+page.ts
export const prerender = true;
export const csr = false;
export const trailingSlash = 'always';
// file (Mochi): src/index.ts
await Mochi.serve({ trailingSlash: 'always', routes });
// hydration is per-component: <Counter mochi:hydrate /> in the page

Streaming / deferred data

Stream slow work with mochi:defer server islands. The deferred component renders out-of-band and swaps in once ready. Its fallback children stay visible until then.

<!-- file (Mochi): src/Home.svelte -->
<SlowRecommendations mochi:defer>
  <p>Loading…</p>
</SlowRecommendations>

See Server islands.

Adapters

Mochi targets Bun through Bun.serve(). Containerize the Bun runtime or deploy to a supported serverless platform. See Deployment.

vite.config.ts

Mochi uses Bun’s bundler and Svelte 5’s compiler. Pass preprocessors through the compile:preprocessors filter. Tweak the compiler in svelte.config.js, which loads automatically (override it with the svelteConfigPath option). See Svelte config.

// file (Mochi): svelte.config.js
export default {
  compilerOptions: { runes: true },
};

See also

See it in action

Live demos showing key concepts from this page