10LOC
Chrome APIsadvanced

Declarative Shadow DOM for server-rendered web components

Published March 5, 2027

<user-card>
  <template shadowrootmode="open">
    <style>
      :host { display: block; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; }
      .name { font-weight: 600; margin: 0; }
    </style>
    <p class="name"><slot name="name">Anonymous</slot></p>
    <p class="bio"><slot name="bio"></slot></p>
  </template>
  <span slot="name">Ada Lovelace</span>
  <span slot="bio">Mathematician and writer.</span>
</user-card>

What

A <template shadowrootmode="open"> nested inside a custom element's markup is recognized by the HTML parser itself and attached directly as that element's shadow root — the shadow tree exists the instant the HTML is parsed, with no attachShadow() call and no JavaScript execution required.

Why it matters

Normally, a web component's shadow DOM only exists after its class's constructor/connectedCallback runs attachShadow() and populates it — which means a server-rendered page containing <user-card> renders as an empty, unstyled tag until the component's JS is fetched, parsed, and executed. That gap is the actual reason web components got a reputation for being SSR-hostile compared to frameworks that ship server-rendered HTML directly: the visual result depends on JS having already run, every time, everywhere.

Declarative Shadow DOM removes that dependency for the first render. The server (or a static-site generator) emits the shadow tree's HTML as part of the page, the parser attaches it as a real, functioning shadow root during the initial parse pass, and the component is visually complete and styled before a single script executes. JS is still needed for behavior — a component still needs customElements.define() to run before event listeners attach or its class's methods do anything — but the visual output no longer waits on it.

How it works

The snippet is what a server would emit for a <user-card> component:

  • The <template shadowrootmode="open"> is consumed by the parser and becomes user-card's real shadow root — inspect this in devtools and there's no <template> element left in the tree at all, just a #shadow-root with the template's former contents inside it.
  • <style> inside the shadow root is scoped to it automatically, same as with imperative shadow DOM — no build-step CSS scoping needed for this component's styles.
  • The <slot> elements project the light-DOM content (<span slot="name">…</span>, <span slot="bio">…</span>) into the shadow tree's layout. This composition works identically whether the shadow root was created declaratively or via attachShadow().
  • A separate, small customElements.define("user-card", …) (not shown as markup — it's the one piece of actual JS this needs) upgrades the already-rendered element so its class's lifecycle methods and any interactive behavior attach. Until that JS runs, the component looks completely correct; it just isn't interactive yet.

Gotchas

  • innerHTML and insertAdjacentHTML() do not process shadowrootmode — only real HTML parsing (the initial page load, or Element.setHTMLUnsafe() / Document.parseHTMLUnsafe() on the client) creates a shadow root from this syntax. Fetch this markup with JS and drop it in via .innerHTML = and you get a literal, inert <template> sitting in the light DOM, not a shadow root — a common surprise when SSR fragments get injected client-side during a SPA transition instead of being present at initial parse.
  • setHTMLUnsafe() is named "Unsafe" deliberately: it parses like innerHTML (no script execution) but performs no additional sanitization. Treat its input exactly as trusted-HTML-only, same trust model as innerHTML itself.
  • Cloning the host node (cloneNode()) after the parser has already attached its declarative shadow root does not preserve that shadow root by default — an imperatively-created shadow root only survives cloning if it was created with attachShadow({ mode, serializable: true }).
  • Older Chrome (90–110) used a now-dead shadowroot attribute (no "mode"). If you find that spelling in older blog posts or code samples, it doesn't work on any current browser — shadowrootmode is the only spelling that ships today.

Browser support: Chrome/Edge 111+, Firefox 123+, Safari 16.4+. Declarative Shadow DOM has been Baseline "newly available" since February 2024 and is on track to reach "widely available" status in August 2026 — safe to rely on across all major engines by the time this posts.

Related

  • Imperative Shadow DOM (attachShadow()) — the client-only mechanism this supplements; still what you use for any shadow root created after initial parse.
  • Custom Elements + connectedCallback — the JS half of the story: what upgrades a declaratively-rendered component into an interactive one.