Skip to content

Framework Integrations

The custom element itself is framework-agnostic. The same built artifact drops into a Svelte, Vue, React or plain HTML page and behaves identically. 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 has to be told that <my-component> is something to 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 it is involved: the element upgrades and hydrates itself, exactly as 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. An asynchronous component needs nothing extra here 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} />;

Synchronous by default, with an RSC opt-in

Section titled “Synchronous by default, with an RSC opt-in”

React’s renderToString cannot await, so an asynchronous component cannot be server-rendered by the default CustomElement.

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.

Apps on a React Server Components framework can server-render these elements instead, from a Server Component:

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

An RSC async function component runs to completion before any markup is emitted, so it can simply await the renderer — no cache, no use(), no Suspense boundary required, and it accepts asynchronous components in either form (given the enable-async opt-in every non-Svelte host needs for that).

It only helps where the element is rendered from a Server Component, though: an async function component cannot be imported into a Client Component ("use client"), so an element rendered from client-side React — whether or not the app is on RSC — keeps the default’s degrade-to-client-only behavior.

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. See Compatibility for every host’s version requirements.