Server rendering
A server-rendered page can use rc-webcomponents today. The server sends the
native markup each component wraps, and that markup works on its own: links
navigate, forms submit, <details> opens, <dialog> shows. The elements
upgrade once the browser loads their definitions. See
Compatibility for the exact contract. This
guide covers the parts that make the window before upgrade look right. For Rails, Laravel, and
ASP.NET Core partials that write the pagination, breadcrumb, and sortable table
markup, see Server templates.
Imports are safe on the server
Every package imports in Node without a DOM. Server frameworks evaluate client
modules while rendering, so a module that imports a component, a type, or a
define entry no longer needs a dynamic import or a client-only wrapper just
to keep the server build from throwing. A define entry that runs on the
server registers the element with Lit's server shim and does nothing else.
lit is a peer dependency of every component package. Install it as a runtime
dependency of your app, not a development one, because the components import
it in production.
Stylesheets for the first paint
Until an element is defined, the browser treats its tag as an unknown inline element. Three stylesheets cover that state, and each is optional.
| Stylesheet | What it does before upgrade |
|---|---|
@rcarls/rc-webcomponents/themes/base.css | Defines the shared --rc-* semantic tokens. |
@rcarls/rc-webcomponents/themes/components-base.css | Imports each light-DOM component's structural styles. These are the same rules the elements inject when they connect. Each package also exports its own base.css. |
@rcarls/rc-webcomponents/themes/pre-upgrade.css | Gives each shadow-DOM element the display it takes once defined, so blocks stack and grids lay out before the definition loads. |
@import '@rcarls/rc-webcomponents/themes/base.css';
@import '@rcarls/rc-webcomponents/themes/components-base.css';
@import '@rcarls/rc-webcomponents/themes/pre-upgrade.css';
All three put their rules in the rc-base cascade layer, below any theme. Load
them before your theme so the theme's layer order applies; the
styling guide explains the layers.
components-base.css uses @import with package specifiers, so it needs a
bundler or CSS tool that resolves packages, such as Vite, Next.js, or Tailwind
CSS. To link a single component's styles directly instead, use its own file,
for example @rcarls/rc-button/base.css.
Skipping the runtime copy
A light-DOM component still injects its structural stylesheet when it
connects, unless the root already holds one. Mark a linked or inlined
stylesheet with data-rc-light-dom-base to say which components it covers:
<link rel="stylesheet" href="/rc/button-base.css" data-rc-light-dom-base="rc-button" />
<link rel="stylesheet" href="/rc/components-base.css" data-rc-light-dom-base="all" />
The value is a space-separated list of tags, or all. A bundler that merges
these files into the app stylesheet drops the attribute, so the elements inject
their copy as well. The rules are identical and sit in the same layer, so the
duplicate costs only a few bytes of parsing.
Register components on the client
Register the components a page uses in one client entry, rather than repeating
the list in every component that renders a tag. In a React app,
@rcarls/rc-react provides RcRegister, which a server component can render
because it takes plain tag names:
import { RcRegister } from '@rcarls/rc-react';
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<RcRegister tags={['rc-app-bar', 'rc-card', 'rc-chip']} />
</body>
</html>
);
}
Server components can then render <rc-card> and the other tags directly.
RcRegister loads each definition after hydration, so React has already
matched the server markup before any element upgrades and starts adding its own
attributes, such as aria-controls or a generated id. The
React guide covers the wrapper
components, which register themselves.
Rendering with Lit's server renderer
Every element renders with @lit-labs/ssr, and
shadow-DOM elements render a declarative shadow root, so a page shows their structure
before any script runs. The CI build renders every element this way.
- On the server, elements cannot see their children, so anything they derive from a native child renders as if it were absent until the browser takes over.
- Server-rendered text uses each element's own
lang, else the default set withsetServerLocale()from@rcarls/rc-common. Set it to the page's<html lang>. - Install a single copy of
@lit-labs/ssr-dom-shim. If your package manager keeps an older copy forlitbeside the one@lit-labs/ssrbrings, nested elements fail to render withgetRootNode is not a function; deduplicate it (yarn dedupe @lit-labs/ssr-dom-shim).
Hydration
To have the elements adopt their server-rendered shadow roots rather than render new ones, load Lit's hydration support before any element definition:
// Client entry, before any define import
import '@rcarls/rc-webcomponents/hydrate-support';
import '@rcarls/rc-webcomponents/rc-card/define';
Each package keeps a server-render fixture for every element it defines, and CI renders it on the server, hydrates it in a browser, and checks that the result matches a client render. Every element passes.
React apps render shadow roots on the server through @lit-labs/ssr-react; the
React guide covers the setup and its limits in
the Next.js App Router.
Pages updated by DOM morphing
Turbo page refreshes, Livewire, htmx with idiomorph, and Blazor's enhanced navigation
update a page by diffing the live DOM against new server HTML instead of replacing it. The
server HTML is your markup, so the diff removes the attributes an element wrote after it
upgraded, such as role, tabindex, ARIA state on its native children, and its state
attributes. The elements keep their identity, so nothing reconnects them.
Every element writes those attributes again after a morph removes them, and none depends on markers in its light DOM that a morph would delete. Reflected properties keep their default values off the host, so a morph against markup without the attribute leaves the default in place. CI morphs each element's server-render fixture against its own markup and checks that the light DOM ends up with every attribute it had before.
A morph applies the server's markup as the page's state. An attribute the server markup
sets, such as open or page, wins over a value the user changed on the client, as it
does for a native element. Exclude a subtree whose client state should survive with your
library's opt-out: data-turbo-permanent, wire:ignore, or hx-preserve.
Elements that announce changes, such as rc-pagination and rc-data-table with
announce and reorderable rc-list items, keep their live region outside your markup:
in the open dialog around the element, else the page body. They look that root up again
before each announcement, so the region survives a morph that removes it and follows a
data-turbo-permanent element into the body Turbo swaps in.