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.
What Every Integration Does
Section titled “What Every Integration Does”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.
Svelte
Section titled “Svelte”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>No async split in Vue
Section titled “No async split in Vue”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:
{ "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 renderers only
Section titled “Synchronous renderers only”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>No client-side counterpart
Section titled “No client-side counterpart”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.
No async split either
Section titled “No async split either”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.
Other Frameworks
Section titled “Other Frameworks”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.
Requirements
Section titled “Requirements”@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.