Server-Side Rendering & Hydration
Server-side rendering for web components is the practice of emitting a component’s shadow tree as static HTML before any JavaScript runs, so the encapsulated content paints on first byte and the client merely adopts it. The enabling primitive is Declarative Shadow DOM — a parser feature that turns a <template shadowrootmode="open"> element into a real shadow root during HTML tokenization, with no script involved.
This guide sits inside Distribution, Testing & Tooling and explains the full mechanism: the parser rules that attach a declarative shadow root, the attributes that configure it, the serialization APIs that produce that markup from a live DOM, the parsing APIs that consume it on the client, the server frameworks that automate it, and the hydration step that reconnects behavior to the already-rendered tree.
This domain is the natural counterpart to Shadow DOM Construction & Modes: that section defines how a shadow root is built imperatively with attachShadow(), while this one defines how the same root is expressed declaratively in markup, transported across the network, and reconnected to script. A component that is flawless in a client-only application can still flash unstyled content or crash on upgrade if its server-rendered form ignores the declarative parsing rules.
Concept definition & spec grounding
Declarative Shadow DOM is defined in the WHATWG HTML Standard as a parser behavior, not a DOM API. When the HTML tokenizer encounters a <template> element carrying a valid shadowrootmode attribute, it does not produce an inert template; instead it creates a shadow root on the template’s parent element and moves the template’s content into that root. By the time the parser finishes, the <template> is gone from the tree and a populated ShadowRoot exists in its place. This is the only mechanism by which a shadow root can exist before any JavaScript executes.
The companion serialization side is defined in the same standard’s HTML serialization algorithm and exposed through Element.getHTML() and ShadowRoot.getHTML(). A shadow root is only included in serialization if it was opted in — either declaratively via shadowrootserializable or imperatively via the serializable: true option on attachShadow(). The serializable flag and getHTML() together form the round trip: a live shadow tree is serialized to a declarative template, sent to a client, and re-parsed into an equivalent live tree.
Because the feature is a parser feature, it composes with the rest of the platform exactly the way ordinary markup does. There is no framework runtime in the critical path, which is what makes it a true framework-agnostic SSR primitive rather than a feature of any single library.
It is worth being precise about what “hydration” means here, because the term is inherited from virtual-DOM frameworks where it implies reconciling a server-rendered tree against a re-executed render function. For native web components the meaning is narrower and cheaper: there is no diff. The DOM the server produced is the DOM; hydration is purely the act of the custom-element upgrade discovering that DOM, adopting its shadow root, and attaching event listeners and reactive state to nodes that already exist. Nothing is re-rendered, no virtual tree is built, and there is no reconciliation pass that can disagree with the markup. The only “mismatch” that can occur is an upgrade that wrongly tries to recreate what the parser already built — which is a logic error in the component, not a divergence between two renderers. That distinction is what makes the adoption pattern below the entire substance of correct hydration.
Browser engine integration points
Declarative Shadow DOM is handled entirely in the HTML parsing phase, before the style and layout engines run. The sequence at the engine level is:
- Tokenization. The parser reads the byte stream and recognizes
<template shadowrootmode="open">. Crucially, this works for the streaming parser as well — the shadow content can be flushed incrementally as the server writes it, so a large page does not block on the full document. - Tree construction. Instead of inserting an
HTMLTemplateElementinto the document tree, the parser calls the internal “attach a shadow root” steps against the template’s parent, honoringmode,clonable,delegatesFocus, andserializablefrom the corresponding attributes. The content is appended to that shadow root. - Style resolution. Any
<style>placed inside the template now lives inside the shadow root, so the style engine scopes it exactly as it would scope a sheet attached imperatively. There is no flash, because the scoped CSS is present before first style recalculation. - Paint. The browser paints the fully styled, encapsulated subtree. No script has run.
- Custom-element upgrade. Later, when the element’s definition is registered (after the defining module loads), the parser-deferred upgrade runs the constructor. At this moment
this.shadowRootis already populated by the declarative root — a fact that dominates the hydration logic discussed below.
Only the most recent, standardized form is described here. Earlier Chromium builds shipped an experimental shadowroot attribute (no mode suffix) that the standardized shadowrootmode replaced; production code should emit and detect shadowrootmode exclusively.
A second engine subtlety governs where the template may appear. The parser only performs the attach step when the template is encountered as the first child of its intended host — once any other element content of the host has been inserted, a subsequent <template shadowrootmode> is treated as an ordinary inert template. This is not an arbitrary restriction: a shadow root, once attached, takes over rendering of the host’s subtree, so it must be established before the host accumulates light-DOM children that would otherwise need to be re-slotted. The practical consequence for a server renderer is that the declarative template must always be emitted immediately after the host’s opening tag, with all slotted light-DOM content following it — a rule examined in detail in rendering declarative Shadow DOM on the server.
There is also a deliberate gap between full-document parsing and fragment parsing. A normal page load runs the full-document parser, which honors shadowrootmode everywhere. But the innerHTML setter runs the fragment parser, which historically — and still, by spec — ignores declarative shadow roots as an injection-safety measure. Attaching a declarative root from a string therefore requires the explicit opt-in fragment APIs (parseHTMLUnsafe, setHTMLUnsafe) rather than innerHTML. This split is the single most surprising part of the engine model and the source of the most common client-side hydration bug.
Core API surface
The declarative markup and its imperative counterparts map one-to-one. The table below is the complete surface a server renderer and a hydration-aware element must agree on.
Declarative attribute (on <template>) |
Imperative equivalent (attachShadow option / property) |
Effect |
|---|---|---|
shadowrootmode="open" |
{ mode: 'open' } |
Creates an open shadow root; host.shadowRoot is readable. |
shadowrootmode="closed" |
{ mode: 'closed' } |
Creates a closed root; reachable only via the upgrade’s internal reference. |
shadowrootclonable |
{ clonable: true } |
The shadow root is cloned when the host is cloned (e.g. via cloneNode(true)). |
shadowrootdelegatesfocus |
{ delegatesFocus: true } |
Focus is delegated into the first focusable descendant. |
shadowrootserializable |
{ serializable: true } |
Opts the root into serialization by getHTML(). |
The serialization and parsing APIs that move a root across the wire:
// SERVER (or any context with a live DOM): serialize a host including its shadow root.
// Only serializable roots are emitted; pass the opt-in flag to include them.
const markup = host.getHTML({ serializableShadowRoots: true });
// → '<my-card><template shadowrootmode="open" shadowrootserializable=""> ... </template></my-card>'
// CLIENT: parse a full document string, attaching any declarative shadow roots.
const doc = Document.parseHTMLUnsafe(markup);
// CLIENT: replace an existing element's subtree, attaching declarative roots within.
host.setHTMLUnsafe('<template shadowrootmode="open"><slot></slot></template>');
The Unsafe suffix is normative, not editorial: parseHTMLUnsafe() and setHTMLUnsafe() do not sanitize, and they are the only fragment-parsing entry points that honor shadowrootmode. The legacy innerHTML setter deliberately ignores declarative shadow roots — assigning a <template shadowrootmode> string to innerHTML inserts an inert template instead of attaching a root.
Production implementation pattern
A hydration-aware element must do one thing differently from a client-only element: it must check whether a declarative shadow root already exists and adopt it rather than recreate it. The following base class is complete and works identically whether the element was server-rendered or constructed entirely on the client.
class SSRElement extends HTMLElement {
static observedAttributes = ['label'];
#root;
#hydrated = false;
constructor() {
super();
// If a declarative shadow root was parsed, this.shadowRoot is already set.
// Adopt it; otherwise create one with matching options.
this.#root =
this.shadowRoot ??
this.attachShadow({ mode: 'open', serializable: true });
}
connectedCallback() {
if (this.#hydrated) return; // idempotent across re-insertions
this.#hydrated = true;
// First client render only: the server already painted the markup.
if (!this.#root.childElementCount) {
this.#root.innerHTML = this.#template();
}
// Wire behavior to the (server- or client-) rendered tree.
this.#root
.querySelector('button')
?.addEventListener('click', this.#onClick);
}
#template() {
return `
<style>
:host { display: inline-block; font: inherit; }
button { padding: .4rem .8rem; border: 1px solid var(--ds-border, #2b3d73); }
</style>
<button>${this.getAttribute('label') ?? 'Activate'}</button>`;
}
#onClick = () => {
this.dispatchEvent(new CustomEvent('activate', { bubbles: true, composed: true }));
};
}
customElements.define('ssr-element', SSRElement);
On the server, the same component is rendered by constructing it in a DOM-like environment and serializing with the opt-in flag, so the emitted HTML carries shadowrootmode="open" and shadowrootserializable. Because the constructor adopts an existing root, the exact same class upgrades cleanly when that HTML reaches the browser.
The serialization side of that round trip looks like this, and it is the bridge between a live component on the server and the declarative markup the client consumes:
// SERVER side: render a component instance and serialize it to declarative HTML.
// Runs in any environment that provides a DOM (jsdom, happy-dom, or @lit-labs/ssr's
// minimal DOM shim). The serializable flag is what makes the shadow root survive.
function renderToString(tagName, attrs = {}) {
const host = document.createElement(tagName);
for (const [name, value] of Object.entries(attrs)) {
host.setAttribute(name, value);
}
document.body.append(host); // triggers upgrade → connectedCallback
// getHTML walks the host AND its opted-in shadow root into one string.
const html = host.getHTML({ serializableShadowRoots: true });
host.remove();
return html;
}
const markup = renderToString('ssr-element', { label: 'Save' });
// → <ssr-element label="Save"><template shadowrootmode="open"
// shadowrootserializable=""><style>…</style><button>Save</button></template></ssr-element>
The key invariant is that the same source drives both directions. The component does not have a separate “server template” and “client template” that can drift apart; it has one #template() method and one constructor that either creates or adopts a root. There is consequently no second renderer whose output could disagree with the first, which is precisely why native web-component hydration has no equivalent of the framework “hydration mismatch warning” — the only failure is a logic error, not a divergence.
Common failure modes & debugging steps
-
NotSupportedErroron upgrade. A constructor that callsthis.attachShadow()unconditionally throws against an element that already has a declaratively-attached root. Root cause: the parser populatedthis.shadowRootbefore the constructor ran, andattachShadow()refuses to attach a second root. Fix: coalesce withthis.shadowRoot ?? this.attachShadow(...)as above. This is covered in depth in avoiding hydration mismatches. -
The template renders as visible markup instead of attaching. Root cause: the
<template>was not the first child of its intended host, or the attribute was misspelled (shadowrootinstead ofshadowrootmode), so the parser treated it as an ordinary inert template. Fix: emit the template as the immediate first child of the host and use the standardized attribute name. This is the focus of rendering declarative Shadow DOM on the server. -
Server-rendered styles flash, then disappear after hydration. Root cause: the client render path runs unconditionally and overwrites the adopted root’s
innerHTML, momentarily clearing the server styles. Fix: guard the client render withif (!this.#root.childElementCount)so an already-populated (server-rendered) root is left untouched. -
getHTML()returns markup without the shadow content. Root cause: the root was attached withoutserializable: true, so it is excluded from serialization. Fix: attach with{ serializable: true }, or emitshadowrootserializabledeclaratively. See serializing shadow roots with getHTML.
Framework interop
React. React does not emit declarative shadow roots from JSX (a <template> with shadowrootmode is rendered by React’s own serializer, which does not run the parser’s attach step). For server-rendered custom elements, render the declarative template as raw markup on the server and let the browser’s native parser attach it; on the client, treat the custom element as an opaque host and let its own upgrade handle hydration. React 19’s improved custom-element property/attribute handling means props map correctly once the element is defined.
Vue. Vue’s SSR can render custom elements as plain tags; configure compilerOptions.isCustomElement so Vue does not try to resolve them as Vue components. The declarative template is passed through as static markup. Slotted light-DOM content authored in a Vue template projects into the server-rendered <slot> exactly as it would client-side.
Angular. Angular Universal renders custom elements as host tags; the declarative shadow content is supplied as static HTML rather than through Angular’s renderer. Angular’s CUSTOM_ELEMENTS_SCHEMA suppresses unknown-element errors during compilation.
Lit SSR (@lit-labs/ssr). This is the most complete framework-agnostic path. Lit SSR renders a Lit component (or any element it can drive) to a string containing a declarative shadow root, including scoped styles, by running the component against a minimal server-side DOM shim rather than a full browser. It emits not only the <template shadowrootmode> markup but also lightweight comment-node markers that record where each dynamic binding sits, so the client can reconnect to those positions without re-rendering. On the client, the @lit-labs/ssr-client hydration support walks those markers, adopts the server-rendered shadow root, and re-establishes the reactive bindings in place. This is the same adopt-the-existing-root principle shown above, generalized to a templating system: the component is never re-rendered, only re-wired.
The framework-agnostic takeaway across all four is consistent. None of these tools attach the shadow root themselves on the client — that is always the browser’s parser. Their job on the server is to produce correct declarative markup, and their job on the client is to drive each element’s upgrade so it adopts, rather than recreates, the root the parser already attached. A plain custom element written with the adopt-or-create constructor needs no client-side framework at all; the framework tooling only adds binding reconnection on top of the same primitive.
Performance & memory implications
The headline benefit is first-contentful-paint independence from script: the styled subtree appears as soon as the HTML arrives, removing the custom-element upgrade from the critical rendering path. This also eliminates the :not(:defined) visibility flash that client-only components require, because there is never an unstyled, undefined phase to hide.
The cost is payload size. Each instance’s <style> is repeated inline in the serialized markup, which inflates HTML for pages with many identical components. Mitigations: hoist shared CSS into a single document-level sheet that components reference via custom properties, gzip/brotli compression (which collapses repeated style blocks effectively), or stream the response so bytes arrive incrementally. Hydration itself allocates no new shadow root — adoption reuses the parsed tree — so the memory profile after hydration matches a client-only render with one fewer allocation per instance.
The leak vectors are identical to client components: listeners bound in connectedCallback must be torn down. Prefer an AbortController whose signal is passed to every addEventListener and aborted in disconnectedCallback, so adoption never accumulates duplicate handlers across re-insertions.
A subtler performance trap is over-eager client work during hydration. Because the server-rendered tree is already painted and interactive-looking, any synchronous measurement, network request, or large DOM mutation run in connectedCallback blocks the main thread precisely when the user perceives the page as ready. Hydration should do the minimum to make the element behave — adopt the root, render only if the root is empty, and attach the critical listeners — and defer everything else with queueMicrotask or requestIdleCallback. The net effect is that time-to-interactive tracks first paint closely, instead of regressing back toward the client-only profile the SSR was meant to escape.
There is also a measurable difference in the shape of the work between the two tiers. A client-only instance pays HTML parsing of its template string, shadow-root allocation, and style-sheet construction at upgrade time; a server-rendered instance has already paid the parsing and allocation costs during the document parse, so its upgrade is dominated only by listener attachment. For pages with many instances this turns a burst of synchronous upgrade work into work the browser already amortized across the streaming parse, which is the deeper reason SSR improves not just first paint but main-thread responsiveness during load.
Browser compatibility & polyfill strategy
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
<template shadowrootmode> (standardized) |
111 (streaming-complete in 124) | 123 | 16.4 |
Legacy shadowroot attribute (do not emit) |
90 (deprecated) | — | — |
serializable option + getHTML({ serializableShadowRoots }) |
125 | 132 | 18.2 |
Document.parseHTMLUnsafe() |
124 | 128 | 17.4 |
Element.setHTMLUnsafe() / ShadowRoot.setHTMLUnsafe() |
124 | 128 | 17.4 |
Declarative Shadow DOM reached cross-engine availability with Firefox 123 (February 2024), joining Chrome 111 and Safari 16.4; Element.getHTML() reached Baseline in September 2024.
Degradation strategy: feature-detect the parser capability with HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode') before relying on declarative attachment. Where the parser lacks support, ship the same component as a client-only element — the this.shadowRoot ?? this.attachShadow(...) constructor pattern means one class serves both tiers, attaching the root imperatively when no declarative root was parsed. For consuming the wire format on older engines, fall back from parseHTMLUnsafe()/setHTMLUnsafe() to a manual walk that finds <template shadowrootmode> nodes and replays attachShadow() for each, since innerHTML will not attach them.
Related
- Rendering declarative Shadow DOM on the server — emitting valid
<template shadowrootmode>and the parser rules that govern attachment. - Avoiding hydration mismatches — adopting an existing declarative root instead of throwing
NotSupportedError. - Shadow DOM Construction & Modes — the imperative
attachShadow()counterpart to declarative roots. - Serializing shadow roots with getHTML — the
serializableflag that makes a root survive serialization. - Distribution, Testing & Tooling — the parent section covering how components travel from source to consumer.