Skip to main content

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.

StylesheetWhat it does before upgrade
@rcarls/rc-webcomponents/themes/base.cssDefines the shared --rc-* semantic tokens.
@rcarls/rc-webcomponents/themes/components-base.cssImports 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.cssGives 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:

app/layout.tsx
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 with setServerLocale() 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 for lit beside the one @lit-labs/ssr brings, nested elements fail to render with getRootNode 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.