Server Rendering
ARC UI components server-render through @lit-labs/ssr intodeclarative shadow DOM: a <template shadowrootmode="open">the browser turns into a real shadow root while parsing, before any JavaScript runs.
All 195 components render this way today, and all of it is optional: a consumer with no build step, or loading ARC from a CDN, needs none of it and loses nothing.
What it fixes
Most ARC components take their content from properties rather than slots. Before JavaScript loads (or without it), this is an empty unknown tag: no heading, no description, nothing for a crawler to read.
<arc-feature-card
heading="Fast by default"
description="Ships 40% less JavaScript"
></arc-feature-card>
The same tag through the server renderer (Lit's hydration markers elided). The content is in the HTML response itself:
<arc-feature-card
heading="Fast by default"
description="Ships 40% less JavaScript"
>
<template shadowrootmode="open">
<link rel="stylesheet" href="/_arc/s-y0rti11lg82db.css">
<div class="card" part="card">
<div class="card__inner" part="inner">
<h3 class="card__title" part="title">
Fast by default
</h3>
<p class="card__desc" part="description">
Ships 40% less JavaScript
</p>
</div>
</div>
</template>
</arc-feature-card>
65 of 195 components are shaped like this: no default slot, no markup to fall back to. Server rendering is the only thing that puts their content in the payload. Slot-driven components already degrade gracefully, and gain less from it.
Rendering and hydrating
On the server, render as you would any Lit template:
import { render } from '@lit-labs/ssr';
import { collectResult } from '@lit-labs/ssr/lib/render-result.js';
import { html } from 'lit';
import '@arclux/arc-ui/feature-card';
const markup = await collectResult(render(html`
<arc-feature-card heading="Fast by default"></arc-feature-card>
`));
On the client, import the hydration entry before any component:
import '@arclux/arc-ui/hydrate'; // first — order matters
import '@arclux/arc-ui/register';
Without it, the moment an element upgrades Lit renders its template from scratch into the shadow root the server already filled, discarding identical DOM and producing the exact flash server rendering exists to avoid. With it, Lit adopts the existing nodes and renders nothing. Order matters because hydration support patchesLitElement's update path: a component class defined before the patch never receives it.
The FOUC guard
base.css hides ARC elements until they upgrade, so a page never flashes unstyled custom elements. A server-rendered element is un-upgraded but finished, so that rule would hide completed content for the whole hydration window, which turns server rendering into a longer blank screen rather than a shorter one.
Opt the page out at the root:
<html data-arc-ssr>
This is a root-level switch rather than something automatic because CSS cannot detect a declarative shadow root: the browser consumes the <template> during parsing and leaves no selectable trace of it.
Component coverage
pnpm check ssr renders every component in Node and fails on any that throws; it runs in CI. Server-side, Lit runs the constructor, willUpdateand render, and none of connectedCallback,firstUpdated, updated, or a reactive controller'shostConnected. Browser work belongs in those.
All 195 pass; none are client-only. There is no per-component support table to consult. You opt in per page, not per component.
Rendering isn't the whole promise, so a second test holds the rest: every component's docs preview, server-rendered, has to look the same before its script arrives as after. That includes the ones built from their children: breadcrumbs, navigation menus, tabs, accordions and lists arrive with their items, and nothing moves when the script lands. Two exceptions, both about the clock: arc-clock shows the time, andarc-activity-heatmap without an end-date ends today. Only the browser knows either.
Client-side routers
A router that fetches the next page and parses it with DOMParser gets declarative shadow roots as inert templates: only the page parser attaches them. Astro's<ClientRouter /> is one. Attach them before the swap:
import { attachShadowRoots } from '@arclux/arc-ui/shadow-roots';
document.addEventListener('astro:before-swap', (e) => attachShadowRoots(e.newDocument));
Without it, every component on the incoming page renders a fresh copy next to a dead template, and nothing hydrates.
Framework support
Lit's framework SSR integrations work from the component graph, which is why only React ever had one. But nothing about the problem needs the graph: a framework's server render produces HTML, and every <arc-*> in it can be rendered to a declarative shadow root from the markup alone. So @arclux/arc-ui/ssr takes HTML and returns HTML, and does not care what produced it.
import { renderDeclarativeShadowDOM } from '@arclux/arc-ui/ssr';
const { html, stylesheets } = await renderDeclarativeShadowDOM(pageHtml);
// serve html; write each stylesheet under /_arc
- Nuxt, SvelteKit, Angular Universal, Next, Astro, or a string you built by hand: pipe the rendered HTML through it. This is the code that renders this site: 177 pages, 43,620 shadow roots, every build.
- React:
@lit-labs/ssr-reactalso works, if you would rather render through the component graph.
It renders any Lit element defined in the process, not only ARC's. Import another library's registration before rendering (@arclux/brand/register, your own components) and its elements arrive rendered too, the way an icon pack's glyphs do.
It needs @lit-labs/ssr, an optional peer dependency. Install it in the project that server-renders. Two things are on the caller: serve the returned stylesheets from the path they were linked with, and import@arclux/arc-ui/hydrate on the client before any component is defined. On a bundler that usually means forcing it into its own chunk, since an import statement alone does not control evaluation order.