Rendering Declarative Shadow DOM on the Server

A server can emit a fully styled, encapsulated shadow tree as static HTML, but only if the markup obeys the parser’s exact attachment rules. The single most common server-side rendering bug is emitting a <template shadowrootmode> that looks correct yet attaches nothing — because it was placed wrong, named wrong, or injected through an API that the parser ignores.

This deep-dive belongs to Server-Side Rendering & Hydration and isolates the rules that decide whether a declarative shadow root attaches at all, then gives a templating helper and client-side parser path that get it right every time.

The minimal reproducible example

Here is a server template that intends to render a card component with scoped styles. It produces markup that renders, but the shadow root never attaches — the styles leak and the <slot> shows nothing.

// BROKEN server helper — emits a template that does NOT become a shadow root.
function renderCard(title, body) {
  return `
    <ds-card>
      <h2 slot="title">${title}</h2>
      <template shadowrootmode="open">
        <style>:host { display: block; border: 1px solid #2b3d73; }</style>
        <slot name="title"></slot>
        <slot></slot>
      </template>
      <p>${body}</p>
    </ds-card>`;
}

And here is a client that tries to hydrate that same string into an existing container:

// BROKEN client path — innerHTML never attaches declarative shadow roots.
container.innerHTML = renderCard('Quarterly report', 'Revenue grew 18%.');
// container.querySelector('ds-card').shadowRoot === null

Both fail, for two independent reasons.

Root-cause analysis

Which inputs attach a declarative shadow root A first-child template parsed by the document parser or the unsafe fragment APIs attaches a real shadow root, while a misplaced template or one assigned through innerHTML stays inert. template = first child + document parser setHTMLUnsafe / parseHTMLUnsafe innerHTML = string or wrong attribute Shadow root attached host.shadowRoot != null Inert <template> host.shadowRoot == null

Failure 1 — the template is not the first child of its host. The WHATWG HTML parser attaches a declarative shadow root only when the <template shadowrootmode> is encountered as the parser is building its parent, and the spec’s tree-construction steps treat it as the shadow root for that parent. In practice this means the template must appear before any other element content of the host. In the broken example, <h2 slot="title"> precedes the template, so by the time the parser reaches the template the host already has light-DOM children and the parser inserts the template as an ordinary inert HTMLTemplateElement instead of attaching a shadow root. The fix is purely positional: the template must be the first child of <ds-card>, with all light-DOM content following it.

Failure 2 — innerHTML does not run the declarative path. Even with a correctly positioned template, assigning the string to innerHTML will not attach the root. The HTML fragment-parsing algorithm invoked by innerHTML deliberately skips declarative shadow root attachment; this is a security decision, because attaching shadow roots from arbitrary string assignment was considered an injection risk. Only the full-document parser (a normal page load) and the explicit opt-in fragment APIs — Document.parseHTMLUnsafe(), Element.setHTMLUnsafe(), and ShadowRoot.setHTMLUnsafe() — honor shadowrootmode. The Unsafe suffix is the spec’s signal that these methods bypass that guard and must therefore only receive trusted markup.

A secondary correctness trap hides behind both failures: the attribute name. The standardized attribute is shadowrootmode. An early Chromium experiment used a bare shadowroot attribute, and code copied from older articles still uses it. The parser ignores shadowroot entirely now, so a typo or a stale snippet silently produces an inert template with no error.

Why does the first-child rule exist at all? A shadow root, once attached, becomes responsible for rendering its host’s subtree — light-DOM children are then projected through <slot> elements rather than rendered directly. If the parser allowed a shadow root to be attached after the host had already accumulated and laid out light-DOM children, those children would have to be retroactively re-parented and re-slotted, which the streaming tree-construction algorithm is not designed to do. By constraining the declarative template to the first-child position, the spec guarantees the shadow root is established before any slottable content is seen, so projection happens in a single forward pass. This is also why the template element itself vanishes from the final tree: its sole purpose was to carry the shadow content during parsing, and once that content is moved into the attached root there is nothing left for it to represent.

The same reasoning explains why innerHTML is excluded. The fragment parser that innerHTML invokes runs in an “already-built” context — it is patching an existing subtree, not constructing a document from a byte stream. Allowing it to attach shadow roots would let any string assignment silently create encapsulated, script-bearing subtrees, which is exactly the injection surface the platform wanted to keep behind an explicit, audibly-named opt-in. Hence setHTMLUnsafe and parseHTMLUnsafe: same fragment-parsing power, but the name forces the author to acknowledge that the input must be trusted.

The production-safe fix

The fix has two halves: a server helper that emits structurally valid declarative Shadow DOM, and a client path that consumes it through a parser API that actually attaches roots, behind a precise feature detection.

// CORRECT server helper.
// Rule 1: the <template> is the FIRST child of the host.
// Rule 2: use the standardized `shadowrootmode` attribute.
// Rule 3: opt into serialization so a later getHTML() round-trips it.
function renderCard(title, body) {
  const esc = (s) =>
    String(s).replace(/[&<>"]/g, (c) =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

  return `<ds-card><template shadowrootmode="open" shadowrootserializable=""` +
    `><style>` +
    `:host{display:block;border:1px solid var(--ds-border,#2b3d73);padding:1rem}` +
    `</style>` +
    `<slot name="title"></slot><slot></slot>` +
    `</template>` +
    `<h2 slot="title">${esc(title)}</h2>` +
    `<p>${esc(body)}</p>` +
    `</ds-card>`;
}
// CORRECT client path.
// Feature-detect the PARSER capability, not just any DSD presence.
const PARSER_SUPPORTS_DSD =
  HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');

function hydrateInto(container, markup) {
  if (PARSER_SUPPORTS_DSD && typeof container.setHTMLUnsafe === 'function') {
    // setHTMLUnsafe runs the declarative-shadow-root-aware fragment parser.
    container.setHTMLUnsafe(markup);
    return;
  }
  // Fallback for engines lacking the parser path: parse inert, then
  // replay attachShadow() for every declarative template manually.
  container.innerHTML = markup;
  for (const tpl of container.querySelectorAll('template[shadowrootmode]')) {
    const host = tpl.parentElement;
    if (!host || host.shadowRoot) continue;
    const root = host.attachShadow({ mode: tpl.getAttribute('shadowrootmode') });
    root.append(tpl.content);
    tpl.remove();
  }
}

The feature detection is deliberately specific. HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode') tests for the IDL property that the platform only exposes once the parser implements declarative attachment, so it does not produce a false positive on engines that recognize the attribute string but never attach. The setHTMLUnsafe existence check then guards the consuming API independently, because an engine could in principle parse full documents with DSD yet lack the fragment-level opt-in method.

When the markup arrives as a whole document rather than a fragment to inject, use Document.parseHTMLUnsafe(markup) instead — it builds a detached Document with all declarative shadow roots already attached, which you can then adopt into the live page:

// Whole-document variant: parse a full server response, then move a subtree in.
function adoptFromDocument(markup, targetSelector) {
  const parsed = Document.parseHTMLUnsafe(markup);   // roots attached here
  const node = parsed.querySelector(targetSelector);
  return node ? document.importNode(node, /* deep */ true) : null;
}

Note that importNode and cloneNode only carry the shadow root across if it was attached as clonable — emit shadowrootclonable (or attach with { clonable: true }) when you intend to duplicate a server-rendered host after parsing, otherwise the clone arrives with an empty shadow root. For the common case of adopting a one-of-a-kind subtree you can move the node directly without cloning, sidestepping the clonable requirement entirely.

Verification

Confirm attachment, not just rendering. A leaking style or an empty slot can look “fine” at a glance, so assert against shadowRoot directly.

const container = document.createElement('div');
document.body.append(container);
hydrateInto(container, renderCard('Quarterly report', 'Revenue grew 18%.'));

const card = container.querySelector('ds-card');
console.assert(card.shadowRoot !== null, 'shadow root must be attached');
console.assert(
  card.shadowRoot.querySelector('style') !== null,
  'scoped style lives inside the shadow root, not the light DOM'
);
console.assert(
  container.querySelector('template[shadowrootmode]') === null,
  'parser consumed the template; none should remain in the tree'
);
// Round-trip: serialization reproduces the declarative form.
console.log(card.getHTML({ serializableShadowRoots: true }));
// → <ds-card><template shadowrootmode="open" shadowrootserializable=""> ... </template> ...

In Chrome DevTools, the attached root appears under the host as #shadow-root (open). If you instead see a literal <template> element sitting in the tree, the parser treated it as inert — re-check the first-child rule and the attribute name. The remaining-template assertion above is the most reliable automated signal that attachment actually happened.

When to use / when to avoid

Situation Approach
Full page server-rendered, custom elements present Emit declarative templates inline; the document parser attaches them on load. Ideal — zero JS for first paint.
Injecting a server-rendered fragment client-side Use setHTMLUnsafe() / parseHTMLUnsafe() with trusted markup only.
Markup comes from an untrusted source Do not use the Unsafe APIs. Sanitize first, or render without declarative shadow roots.
Target includes engines below the support matrix Ship the manual attachShadow() replay fallback shown above.
Many identical instances on one page Acceptable, but hoist shared CSS to document level via custom properties to avoid repeating each <style>.
Light-DOM content must precede the shadow template visually Not possible — the template must be the first child. Use slot ordering inside the shadow root to control layout instead.