Skip to content

SSR

Browsers know how to instantiate custom elements. A server renderer usually sees an unknown HTML tag and has no component implementation to invoke.

Svebcomponents bridges that gap with a server-specific renderer for each custom element. The design follows Lit’s ElementRenderer model and serializes the component’s shadow root using declarative shadow DOM.

A component package exposes its browser entrypoint and a matching SSR entrypoint:

{
"exports": {
".": {
"types": "./dist/client/index.d.ts",
"svelte": "./dist/client-svelte/index.js",
"default": "./dist/client/index.js"
},
"./ssr": {
"types": "./dist/server/ssr.d.ts",
"svelte": "./dist/server-svelte/ssr.js",
"default": "./dist/server/ssr.js"
}
}
}

The browser entrypoint defines the custom element. During the build, the SSR entrypoint is generated as an ElementRenderer subclass that knows how to render the Svelte component on the server.

The optional svelte conditions create renderer and browser builds that share the consuming application’s Svelte runtime. They carry the same version-compatibility limitation described in Svelte Conditional Exports.

See Build for how matching /ssr exports enable this output.

The consuming application needs two pieces of setup.

First, add the Svebcomponents transform before the Svelte plugin:

import { sveltekit } from "@sveltejs/kit/vite";
import svebcomponents from "@svebcomponents/ssr/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [svebcomponents(), sveltekit()],
});

The plugin automatically adds @svebcomponents/ssr to Vite’s ssr.noExternal — you don’t need to configure that yourself. If your own component package also ships a raw-.svelte export (e.g. an externalSvelte build sharing this app’s Svelte runtime), add it alongside via the plugin’s noExternal option instead of editing vite.config.ts’s ssr.noExternal separately:

svebcomponents({ noExternal: ["my-component-package"] });

Then load the component’s server renderer once, e.g. from hooks.server.ts:

import "my-component-package/ssr";

The generated renderer knows its own tag name (read from the component’s <svelte:options customElement> declaration at build time) and registers itself with ElementRendererRegistry as soon as it’s imported. The DOM shim is guaranteed to install first regardless of import order or how a bundler chunks things.

Any Custom Element, Not Just Svelte-built Ones

Section titled “Any Custom Element, Not Just Svelte-built Ones”

Nothing in the SSR pipeline is specific to how a custom element is implemented. Rendering goes through Lit’s ElementRenderer contract and nothing else, so any element with a conforming renderer can be server-rendered — including Lit elements:

import { LitElementRenderer } from "@lit-labs/ssr/lib/lit-element-renderer.js";
import { ElementRendererRegistry } from "@svebcomponents/ssr";
ElementRendererRegistry.use(LitElementRenderer);

use() registers a renderer that selects the elements it serves itself, through Lit’s static matchesClass hook, rather than being bound to one tag. That single line therefore covers every Lit element in the app. An explicit ElementRendererRegistry.set() registration still wins, so one specific element can be given a different renderer.

The reverse direction works too: the renderers Svebcomponents generates implement matchesClass, so they can be handed straight to @lit-labs/ssr’s own render():

import { render } from "@lit-labs/ssr";
import MyComponentRenderer from "my-component-package/ssr";
render(html`<my-component title="Hello"></my-component>`, {
elementRenderers: [MyComponentRenderer],
});

Both directions are covered by end-to-end tests in the repository.

Svelte gates asynchronous server rendering behind a module-global flag, which is normally set for you by a Svelte app compiled with compilerOptions.experimental.async.

A host application that is not a Svelte app has no such compilation, and a component package’s own server bundle cannot set the flag either — it carries its own copy of Svelte, while render() is called through the copy @svebcomponents/ssr imports. Such an app must therefore opt in explicitly on the server:

import "@svebcomponents/ssr/enable-async";

Without it, render() runs synchronously even when awaited, and a component that performs asynchronous work throws await_invalid. This is only needed outside Svelte hosts; a SvelteKit app compiled with experimental.async already has it.

On the server, the Vite plugin finds custom element tags in Svelte templates and rewrites them to a wrapper component. The wrapper:

  1. Looks up the custom element and its registered renderer.
  2. Passes attributes and properties to that renderer.
  3. Emits the rendered shadow root as declarative shadow DOM.

On the client, the wrapper renders the original custom element tag so the browser can upgrade it normally. When the element upgrades, the server-rendered shadow DOM is hydrated — adopted in place — rather than wiped and re-rendered.

The examples above use Svelte. Nothing about the element is Svelte-specific — only the host application’s wiring is. For Vue, and for building an integration against another framework, see Framework Integrations.

For runtime APIs, direct package usage, transform details, and the complete list of limitations, see the @svebcomponents/ssr reference.