Installation
npm install @tuvix.js/svelte svelte
# or pnpm add @tuvix.js/svelte svelteThe peer range covers Svelte 3, 4 and 5. The package has two entry points: @tuvix.js/svelte for the browser and @tuvix.js/svelte/server for server rendering.
When to use it
Use this package when a Svelte component should run as a Tuvix.js micro app: the orchestrator mounts it into a container on a route, passes props, updates them, and unmounts it when the route changes. It also covers islands: you render the component on the server, put the HTML in the page, and the client hydrates it.
You do not need it for a page that Svelte owns from top to bottom (SvelteKit). In that case Svelte mounts itself and there is no orchestrator to talk to.
Quick start
A Svelte 5 component with props:
<!-- Profile.svelte -->
<script lang="ts">
let { name, theme = 'light' }: { name: string; theme?: string } = $props();
</script>
<p class={theme}>Hello {name}</p>The micro app entry wraps it. Passing SvelteMap lets prop updates re-render the component in place:
// profile-app.ts
import { SvelteMap } from 'svelte/reactivity';
import { createSvelteMicroApp } from '@tuvix.js/svelte';
import Profile from './Profile.svelte';
export default createSvelteMicroApp({ name: 'profile', App: Profile, SvelteMap });The shell registers the app and starts the orchestrator:
// shell.ts
import { createOrchestrator } from '@tuvix.js/core';
const orchestrator = createOrchestrator();
orchestrator.register({
name: 'profile',
entry: () => import('./profile-app'),
container: '#profile',
activeWhen: '/profile',
props: { name: 'Ada' },
});
await orchestrator.start();
// Later: the component re-renders in place, no remount.
await orchestrator.updateAppProps('profile', { name: 'Grace', theme: 'dark' });The client bundle must resolve svelte to its browser build. @sveltejs/vite-plugin-svelte does that for you. If your setup drops the browser export condition, Svelte 5 mount() throws lifecycle_function_unavailable and onMount never runs.
API
createSvelteMicroApp
function createSvelteMicroApp(config: SvelteMicroAppConfig): MicroAppModule;| Option | Type | Default | Meaning |
|---|---|---|---|
name | string | required | App name. The adapter registers the module under it with registerModule from @tuvix.js/loader. |
App | unknown | required | Root component: a compiled Svelte 5 component (a function) or a Svelte 3/4 component class. |
bootstrap | () => void | Promise<void> | none | One-time setup. The module's bootstrap() awaits it. |
ssr | boolean | false | Hydrate server markup when the container holds elements or non-whitespace text. Otherwise render fresh. |
SvelteMap | SvelteMap class from svelte/reactivity | none | Svelte 5 only. With it, update() re-renders in place; without it, a Svelte 5 update() remounts the component. Svelte 3/4 ignore it. |
On the first mount the adapter imports svelte and checks for a mount export. It caches the result for the page.
- Svelte 5 (
mountexists): renders withmount(), hydrates withhydrate(), tears down withunmount(). Howupdate()works depends onSvelteMap:- With
SvelteMap, the component receives a props object backed by aSvelteMap.update()writes the new values into that map, so the parts of the template that read a changed prop re-render and the component keeps its DOM nodes and state. This works for$props()components and for legacyexport letcomponents compiled by Svelte 5. - Without it,
update()unmounts the instance, empties the container, mounts a new instance with the new props, and dispatchestuvix:mountedagain withhydrated: false. DOM nodes, focus and component state are lost.
- With
- Svelte 3/4 (no
mount):new App({ target, props, hydrate }),$set()and$destroy().
The returned module works per container. Each container gets its own instance, so one module can render into three containers at once.
mount({ container, props })clears the container (unless it hydrates), renders, runsonMountcallbacks and effects, then dispatchestuvix:mountedon the container withhydrated: true | false. A failure dispatchestuvix:errorand rejects with the original error.update({ props, container? })updates the instance incontainer, or every instance when you leavecontainerout. Each update replaces the whole prop set: a key missing from the new props falls back to the component's default on Svelte 5 and becomesundefinedon Svelte 3/4.unmount({ container })destroys the instance, empties the container and dispatchestuvix:unmounted.
import { SvelteMap } from 'svelte/reactivity';
import { createSvelteMicroApp } from '@tuvix.js/svelte';
import { onLifecycle } from '@tuvix.js/loader';
import Cart from './Cart.svelte';
const cart = createSvelteMicroApp({ name: 'cart', App: Cart, SvelteMap });
onLifecycle('mounted', ({ name, hydrated }) => {
console.log(`${name} mounted`, { hydrated });
});
const header = document.querySelector<HTMLElement>('#cart-header')!;
const sidebar = document.querySelector<HTMLElement>('#cart-sidebar')!;
await cart.mount({ container: header, props: { compact: true } });
await cart.mount({ container: sidebar, props: { compact: false } });
// Updates the sidebar instance only.
await cart.update?.({ props: { compact: true }, container: sidebar });createSsrSvelteMicroApp
function createSsrSvelteMicroApp(config: Omit<SvelteMicroAppConfig, 'ssr'>): MicroAppModule;Same as createSvelteMicroApp({ ...config, ssr: true }). Use it on the client for islands that the server rendered with renderSvelteToString or renderSvelte.
- Svelte 5:
hydrate(App, { target, props }). The markup must come fromsvelte/server, because hydration reads the comment markers (<!--[-->) it writes. - Svelte 3/4:
new App({ target, props, hydrate: true }). Compile the component withhydratable: true.
Hydration keeps the server's DOM nodes and attaches event handlers to them. A container that holds only whitespace or comments gets a fresh render.
import { createSsrSvelteMicroApp } from '@tuvix.js/svelte';
import Footer from './Footer.svelte';
export default createSsrSvelteMicroApp({ name: 'footer', App: Footer });renderSvelteToString
// import from '@tuvix.js/svelte/server'
function renderSvelteToString(
App: unknown,
props?: Record<string, unknown>,
options?: RenderSvelteOptions
): Promise<string>;| Option | Type | Default | Meaning |
|---|---|---|---|
render | SvelteServerRender | none | render from svelte/server. Without it the adapter imports svelte/server at runtime. |
Renders the component on the server and returns its <style> tags followed by its markup. Put the string inside the island container and mount it with createSsrSvelteMicroApp. The string leaves out <svelte:head> content; use renderSvelte when you need it.
The function picks the Svelte version at runtime. With render passed, it always takes the Svelte 5 path and calls render(App, { props }). Without it, the adapter imports svelte: if that module exports mount, it loads svelte/server and renders with Svelte 5. Otherwise it calls App.render(props) (Svelte 3/4, compiled with generate: 'ssr') and wraps css.code in a <style> tag. The adapter never decides by checking App.render, because Svelte 5 components compiled with dev: true (vite-plugin-svelte in serve mode) carry an App.render stub that throws.
Call it from a server function, not from code that also runs in the browser. In a browser it returns ''.
import { render } from 'svelte/server';
import { renderSvelteToString } from '@tuvix.js/svelte/server';
import Footer from './Footer.svelte';
export async function renderFooterIsland(): Promise<string> {
const html = await renderSvelteToString(Footer, { year: 2026 }, { render });
return `<footer id="footer" data-tuvix-app="footer">${html}</footer>`;
}renderSvelte
// import from '@tuvix.js/svelte/server'
function renderSvelte(
App: unknown,
props?: Record<string, unknown>,
options?: RenderSvelteOptions
): Promise<SvelteRenderResult>;Takes the same arguments and options as renderSvelteToString and returns the parts separately:
| Field | Meaning |
|---|---|
html | Component markup. Svelte 5 output carries the hydration comments. |
head | <svelte:head> content without the component <style> tags. Put it in the document <head>. |
css | Component CSS as <style> tags, or '' when the compiler emitted none. |
In a browser all three fields are ''.
import { render } from 'svelte/server';
import { renderSvelte } from '@tuvix.js/svelte/server';
import Article from './Article.svelte';
export async function renderArticlePage(slug: string): Promise<string> {
const { html, head, css } = await renderSvelte(Article, { slug }, { render });
return `<!doctype html>
<html>
<head>${head}${css}</head>
<body>
<main id="article" data-tuvix-app="article">${html}</main>
<script type="module" src="/client.js"></script>
</body>
</html>`;
}Types
| Type | Entry | Meaning |
|---|---|---|
SvelteMicroAppConfig | @tuvix.js/svelte | Options of createSvelteMicroApp. |
SvelteProps | @tuvix.js/svelte | Record<string, unknown>: the props the orchestrator passes to the component. |
RenderSvelteOptions | @tuvix.js/svelte/server | { render?: SvelteServerRender }. |
SvelteServerRender | @tuvix.js/svelte/server | Shape of Svelte 5 render from svelte/server. |
SvelteRenderResult | @tuvix.js/svelte/server | { html, head, css }, returned by renderSvelte. |
Server rendering
An island goes through three steps: render on the server, ship the HTML inside the container, hydrate on the client.
- Call
renderSvelteToString(orrenderSvelte) in a server function and put the result in the container element. - Load the client entry, which exports
createSsrSvelteMicroApp({ name, App }). - The orchestrator mounts the app into the same container. The adapter sees markup and hydrates it.
CSS. Svelte 5 puts component CSS in the render output only when you compile with css: 'injected'. With the default css: 'external', your bundler writes the CSS to a file and the string holds no <style> tags. In Vite:
// vite.config.ts
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ compilerOptions: { css: 'injected' } })],
});If you keep css: 'external', link the generated stylesheet from the page head. @tuvix.js/vite tells your server which stylesheets an island entry needs.
One copy of Svelte. Pass render from svelte/server when your server bundle includes Svelte (Nitro, Vinxi, a Vite SSR build with noExternal). The component and the renderer then share one Svelte copy. Without the option, the adapter imports svelte/server at runtime, which can load a second copy from node_modules. Svelte's server renderer uses instanceof checks and fails when the component and the renderer come from different copies.
Edge cases
mounton a container that already holds an instance of the same module unmounts the old instance first.unmountcalled whilemountis still loadingsveltewaits for the mount to finish, then destroys the instance. No orphan component stays behind.unmounton a container this module never mounted, or whose mount failed, does nothing: the container keeps its children and no event fires.updatecalled while a mount is pending waits for it, then applies the props. An update for a mount that failed does nothing.- On Svelte 3/4, a prop missing from the new props becomes
undefined. Theexport let name = 'x'default does not come back. On Svelte 5 the default applies, with or withoutSvelteMap. - The adapter does not import
svelte/reactivityitself. Svelte 3/4 have no such module, and a bundler would fail to build a Svelte 3/4 app that referenced it, so the import lives in your Svelte 5 entry. - A Svelte 5 remount (no
SvelteMap) dispatchestuvix:mountedagain but notuvix:unmountedin between. If the new render throws,update()rejects with the error and the old instance is already gone. - Without
ssr,mountclears the container before rendering, so loading placeholders disappear. - With
ssr, the adapter hydrates when the container has an element or non-whitespace text. Comments and whitespace alone trigger a fresh render. - The adapter detects the Svelte version once per page. If
import('svelte')fails, the next mount retries. - A Svelte 5 import or render error on the server reaches you as a rejected promise. The adapter does not fall back to
App.render. - Svelte 5
rendermay return a promise (async server rendering). Both server functions await it.
What it does not do
- It does not compile
.sveltefiles. Your bundler does that with@sveltejs/vite-plugin-svelteorsvelte/compiler. - It does not check which Svelte build your bundler picked. A client bundle that resolves
svelteto the server build fails at mount. - It passes props only. Slots, snippets and component events have no channel from the host. Use
@tuvix.js/event-busto talk across apps. - It does not write
<svelte:head>content into the document.renderSveltereturns it; your server puts it in<head>. - It does not link CSS compiled with
css: 'external'. See@tuvix.js/vite. - It does not wait for data on the server. Load it first and pass it as props.
Migrating from 0.4
- Svelte 5 works. In 0.4 the adapter called
new App()on every component, so Svelte 5 components failed, andcreateSsrSvelteMicroApprendered a second copy instead of hydrating. Mounting needs no code change. For in-place prop updates, passSvelteMapfromsvelte/reactivityin the config; without it, each Svelte 5 update remounts the component. - The container is cleared on mount. Without
ssr, Svelte 3/4 used to append the component after any markup already in the container. Now the adapter empties the container first. If you relied on markup staying next to the component, move it outside the container. - No silent fallback on the server.
renderSvelteToStringused to fall back toApp.renderwhensvelte/serverfailed to load. It now takes the Svelte 5 path when you passrenderor whensvelteexportsmount, callsApp.renderonly on Svelte 3/4, and rejects on a Svelte 5 import or render error. Svelte 5 dev builds in Vite dev SSR take the Svelte 5 path even though they carry anApp.renderstub. unmounton an unknown container is a no-op.unmount({ container })on a container the module never mounted used to empty it. It now leaves the container alone, like the React, Vue and Angular adapters.- Pass
render. Add{ render }fromsvelte/serveras the third argument when your server bundle includes Svelte:
import { render } from 'svelte/server';
import { renderSvelteToString } from '@tuvix.js/svelte/server';
import Footer from './Footer.svelte';
// 0.4: await renderSvelteToString(Footer, { year: 2026 });
const html = await renderSvelteToString(Footer, { year: 2026 }, { render });See the Svelte guide for a full app.