Skip to content

Framework Integrations

The custom element itself is framework-agnostic. It is a web component: the same built artifact drops into a Svelte app, a Vue app, a React app, or a plain HTML page, and behaves identically in all of them. Nothing on this page changes the component.

What differs is one server-side detail. Server rendering has two halves — producing the element’s shadow DOM, which the component’s ElementRenderer does identically everywhere, and getting that markup into the host framework’s output, which every framework spells differently. A host framework has to be told that <my-component> is something it should ask a renderer about, and the resulting markup has to be shaped so the framework hydrates around the element rather than re-creating it.

An integration supplies only that second half. In the browser, none of this is involved at all: the element upgrades and hydrates itself, exactly as it would on a page with no framework.

On the server, an integration emits the element with its shadow root inline:

<my-component title="Hello">
<template shadowrootmode="open"><!-- shadow content --></template>
<!-- light dom children -->
</my-component>

The <template shadowrootmode> must be the element’s first child. The HTML parser adopts it into a shadow root and removes it from the light DOM before any script runs, which is what makes declarative shadow DOM work at all.

In the browser the integration renders the same element without the template, because by then the parser has already consumed it. The host framework hydrates against the post-parse child list, so both sides agree, and the element itself hydrates its shadow root in place.

See the SSR guide for the full walkthrough. In short, a Vite plugin rewrites custom element tags in .svelte templates to a wrapper component:

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

Svelte is the only integration with a synchronous/asynchronous split. Its async wrapper uses await in the template, which requires the host app itself to be compiled with compilerOptions.experimental.async.

Install @svebcomponents/ssr-vue and add its Vite plugin ahead of @vitejs/plugin-vue:

import vue from "@vitejs/plugin-vue";
import svebcomponentsVue from "@svebcomponents/ssr-vue/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [svebcomponentsVue(), vue()],
});

Register the wrapper component on both the server and client app instances:

import { svebcomponents } from "@svebcomponents/ssr-vue";
app.use(svebcomponents());

Then write custom elements in any single-file component:

<template>
<my-component title="Hello" :count="5" />
</template>

Vue’s renderToString fully awaits an async setup() and emits its output in order, so one wrapper handles both synchronous and asynchronous element renderers. Nothing extra is needed for a component that awaits while rendering or that has an async SsrPrepare hook — beyond the enable-async opt-in every non-Svelte host needs.

Why a Vite plugin rather than a compiler transform

Section titled “Why a Vite plugin rather than a compiler transform”

Vue exposes compilerOptions.nodeTransforms, which looks like the natural place to retag custom elements. It does not work for server rendering: user node transforms run after the built-in ones, and @vue/compiler-ssr resolves a component’s identity before they get a chance to rename the tag. The result is an app that renders correctly in the browser and not at all on the server.

Rewriting the single-file component’s source before @vitejs/plugin-vue sees it means the client and server compiles observe the same template.

Install @svebcomponents/ssr-react and point the JSX transform at it:

tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@svebcomponents/ssr-react",
},
}

That is the whole setup. Any tag containing a dash is then routed through the integration:

<my-component title="Hello" count={5} />

Unlike the Svelte and Vue integrations there is no bundler plugin, because the JSX runtime swap is a compiler setting. React is therefore the one integration that does not require Vite — it works in any React setup.

If you would rather not swap the JSX runtime, use the wrapper directly:

import { CustomElement } from "@svebcomponents/ssr-react";
<CustomElement tag="my-component" title="Hello" count={5} />;

React’s renderToString cannot await, so a component that performs asynchronous work — one that awaits while rendering, or that has an async SsrPrepare hook — cannot be server-rendered by this integration today.

Rather than failing the page, such an element is emitted without server-rendered shadow content and rendered in the browser only, with a one-time console warning naming the tag. Genuine configuration errors, such as an element with no registered renderer, still throw.

This is a limitation of the integration rather than of React: React’s streaming renderer does preserve declarative shadow DOM. See the async SSR findings for the measured behavior and the designs under consideration.

Add the integration. That is the entire configuration:

import { defineConfig } from "astro/config";
import svebcomponents from "@svebcomponents/ssr-astro";
export default defineConfig({
integrations: [svebcomponents()],
});

Custom elements then work in any .astro template:

<my-component title="Hello" count="5">
<p>light dom child</p>
</my-component>

Astro is the one integration with nothing to register in the browser, because Astro ships no client JavaScript for these elements. There is no host render to match against: the parser attaches the shadow root, the element upgrades when its bundle loads, and it hydrates itself.

The exception is islands. A custom element inside a client:* React, Vue or Svelte island belongs to that framework, and the corresponding integration applies there instead.

Astro frontmatter is an async module scope, so the wrapper awaits the element renderer directly — asynchronous components need nothing extra beyond the enable-async opt-in every non-Svelte host needs.

The pieces an integration is built from are exported for reuse:

import {
renderCustomElement,
renderCustomElementSync,
} from "@svebcomponents/ssr";
const { attributes, shadowTemplate, shadowContent } = await renderCustomElement(
"my-component",
{ title: "Hello" },
);

Use renderCustomElementSync when the host’s rendering pipeline cannot await — it throws AsyncRendererError if the element turns out to need awaiting, so the host can degrade deliberately — and renderCustomElement when it can. shadowTemplate is the complete <template shadowrootmode="open">…</template> string for hosts that emit raw markup; shadowContent is its inner markup, for hosts that build the template element themselves.

Since the pipeline only depends on Lit’s ElementRenderer contract, an integration written this way renders any custom element with a registered renderer, not only Svebcomponents ones.

@svebcomponents/ssr calls Svelte’s server renderer, so svelte must be installed as a server-side dependency of the host application even when it contains no Svelte components of its own. A component package’s server bundle ships its own Svelte runtime, but the renderer entry point does not.