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