June 25, 2025
  • All
  • Product
  • Stencil
  • next
  • rendering
  • server
  • side
  • SSR
  • stencil

The Quest for SSR with Web Components: A Stencil Developer’s Journey

Stencil Team

Picture this: You’ve built a beautiful design system using Web Components. Your components are framework-agnostic, encapsulated, and performant. Life is good… until someone asks, “But does it work with SSR?”

Record scratch. Freeze frame.

That’s where our story begins.

The Promise Land: SSR and Web Components

Before we dive into the trenches, let’s set the stage. Server-Side Rendering (SSR) is the golden child of modern web development. It promises:

  • Lightning-fast initial loads — Users see content immediately, no more staring at blank screens
  • SEO superpowers — Search engines love fully-formed HTML
  • Enhanced security — Keep your secrets on the server where they belong

Meanwhile, Web Components offer their own slice of paradise:

  • Framework agnosticism — Works with React, Vue, Angular, or vanilla JS
  • True encapsulation — Shadow DOM keeps your styles from leaking everywhere
  • Native performance — No framework overhead, just pure browser APIs

So naturally, combining these two should create the ultimate web development utopia, right?

Narrator: It did not.

When Worlds Collide: The Challenges

Here’s the thing about Web Components — they’re inherently client-side creatures. They rely on browser APIs like customElements.define() and the Shadow DOM, which don’t exist in Node.js land. It’s like trying to teach a fish to climb a tree.

The main challenges we faced:

1. The Browser Dependency Problem

Web Components love their browser APIs (window, document, localStorage). Servers? Not so much. Running these components on the server is like asking your coffee machine to make tea — technically possible, but requires some creative engineering.

2. Shadow DOM Shenanigans

The Shadow DOM is fantastic for encapsulation, but servers don’t speak Shadow DOM. We needed to somehow replicate this encapsulation server-side, which led us down the rabbit hole of Declarative Shadow DOM.

3. The Hydration Headache

Hydration — the process where client-side JavaScript takes over server-rendered HTML — becomes particularly tricky with Web Components. It’s like performing a magic trick where you swap a cardboard cutout with a real person without anyone noticing.

Enter the Arena: Different Approaches

Lit’s Take: “Keep It Simple”

Lit approached SSR with elegance. Their solution renders components to static HTML using a low-level library that doesn’t fully emulate the DOM. Here’s what a Lit component looks like when server-rendered:

<simple-greeter name="Friend">

  <template shadowroot="open" shadowrootmode="open">

    <style>/* component styles */</style>

    <div>

      <h1>Hello, <span>Friend</span>!</h1>

      <p>Count: 0</p>

      <button>++</button>

    </div>

  </template>

</simple-greeter>

Clean, declarative, and it works. But here’s the catch — there aren’t many real-world examples of this being used in large-scale design systems. It’s like having a sports car that looks amazing but you’re not quite sure how it handles on actual roads.

Stencil’s Journey: The Kitchen Sink Approach

At Stencil, we decided to throw everything at the problem. Our toolkit includes:

  • A hydrate module that creates a virtual DOM environment (MockDoc) on the server
  • renderToString and streamToString functions for serialization
  • serializeProperty to handle complex objects, Maps, Sets, and even Infinity (because why not?)

Here’s the twist: we ended up with TWO different approaches because one size definitely doesn’t fit all.

The Tale of Two Strategies

Strategy 1: The Compiler Approach (When You Need Universal SSR)

Our compiler-based approach, implemented in the @stencil/ssr package, works like a skilled translator at a United Nations meeting. It operates as a Vite or Webpack plugin, intercepting your code at build time and performing some serious AST (Abstract Syntax Tree) surgery.

Here’s the magic: when you write this innocent-looking React component:

import { MyComponent } from 'component-library-react'

function App() {

  return (

    <div>

      <h1>SSR Test</h1>

      <MyComponent first="Stencil" middleName="'Don't call me a framework'" last="JS" />

    </div>

  )

}

The plugin receives it as a pile of jsx calls after transformation:

import { jsxDEV } from "react/jsx-dev-runtime";

/* @__PURE__ */ jsxDEV(MyComponent, { 

  first: "Stencil", 

  middleName: "'Don't call me a framework'", 

  last: "JS" 

}, void 0, false, ...this)

Now comes the clever part. The plugin:

  1. Parses this JavaScript into an AST
  2. Identifies which components need SSR
  3. Analyzes the props being passed
  4. Calls our hydrate module to pre-render the component
  5. Replaces the original component with a wrapper containing the pre-rendered HTML

The result? Your MyComponent becomes MyComponent$0:

const MyComponent$0 = ({ children, ...props }) => {

  return <my-component class="hydrated sc-my-component-h" 

                       first="Stencil" 

                       last="JS" 

                       middle-name="'Don't call me a framework'" 

                       s-id="1" 

                       suppressHydrationWarning={true} 

                       {...props}>

    <template shadowrootmode="open" suppressHydrationWarning={true} 

              dangerouslySetInnerHTML={{ 

                __html: `<style>:host{display:block;color:green}</style>

                         <div c-id="1.0.0.0" class="sc-my-component">

                           <!--t.1.1.1.0-->

                           Hello, World! I'm Stencil 'Don't call me a framework' JS

                         </div>` 

              }}></template>

    {children}

  </my-component>

}

For Next.js specifically, we go even fancier with dynamic imports:

const MyComponent$0Instance = dynamic(

  () => componentImport.then(mod => mod.MyComponent),

  {

    ssr: false,

    loading: () => <MyComponent$0 {...props}>{children}</MyComponent$0>

  }

)

The Pros:

  • Works with any React meta-framework (Vite, Remix, Next.js)
  • Handles deeply nested component compositions beautifully
  • No runtime overhead on the server
  • Clean separation of concerns

The Cons:

  • Can’t resolve dynamic props at compile time (e.g., prop={calculateValue()})
  • No access to Light DOM during serialization
  • Still prone to occasional hydration mismatches
  • Requires build-time configuration

Strategy 2: The Runtime Approach (When You’re All-In on Next.js)

The runtime approach is like having a personal chef instead of meal prep. When Next.js hits a Stencil component during server rendering, we spring into action in real-time.

This approach leverages Next.js Server Components, which support async operations. Here’s how it works:

  1. Component Analysis: When the server encounters a Stencil component, we intercept it
  2. Prop Serialization: We use serializeProperty to handle all props, including complex objects
  3. Children Transformation: Here’s where it gets interesting — we attempt to transform the React children into a string using react-dom/server
  4. Async Rendering: We call Stencil’s renderToString (which returns a Promise) right there on the server
  5. React Node Recreation: We parse the resulting HTML back into React nodes using html-react-parser

The implementation looks something like this:

// Server Component Wrapper

async function MyComponentSSR({ children, ...props }) {

  // Serialize all props for Stencil

  const serializedProps = Object.entries(props)

    .map(([key, value]) => `${key}="${serializeProperty(value)}"`)

    .join(' ');

  // Attempt to render children to string

  let childrenHtml = '';

  try {

    childrenHtml = ReactDOMServer.renderToString(children);

  } catch (e) {

    // Handle nested server components gracefully

    console.warn('Complex children detected, using fallback');

  }

  // Render the component with Stencil's SSR

  const { html } = await renderToString(

    `<my-component ${serializedProps}>${childrenHtml}</my-component>`,

    { prettyHtml: true }

  );

  // Parse back to React

  return parseHtmlToReact(html, { 

    suppressHydrationWarning: true 

  });

}

The Pros:

  • Full access to all props at runtime
  • Can include Light DOM in serialization
  • Handles dynamic values perfectly
  • Enables true isomorphic rendering

The Cons:

  • Next.js only (Server Components required)
  • Requires managing dual component wrappers (client + server)
  • Performance overhead from runtime serialization
  • Complex children (multiple server components) can fail
  • Higher hydration mismatch risk

Choosing Your Fighter

So which approach should you use? Here’s our battle-tested decision tree:

  • Use the Compiler Approach when:
    • You need to support multiple frameworks
    • Performance is critical
    • Your components have predictable props
    • You want a “set it and forget it” solution
  • Use the Runtime Approach when:
    • You’re committed to Next.js
    • You need full Light DOM access
    • Your props are highly dynamic
    • You’re okay with more complexity for more power

In practice, we’ve seen teams start with the compiler approach for its simplicity and broader compatibility, then selectively use the runtime approach for specific components that need its advanced features.

The Plot Thickens: More Challenges

The Bloat Monster

Remember how we embed styles in each component’s Declarative Shadow DOM? Well, imagine a button component used 50 times on a page. That’s the same styles repeated 50 times. Your HTML document becomes chonkier than a sumo wrestler.

Our workaround? Stencil’s “scoped components” — fake web components that transform into real ones on the client. It’s like shipping IKEA furniture instead of assembled pieces.

The Conditional Rendering Conundrum

Complex components that render differently based on conditions or child nodes? That’s where things get spicy. We introduced build flags to help:

render() {

  if (Build.isServer) {

    return <div>loading...</div>

  }

  return <ul>{/* actual content */}</ul>

}

It’s not elegant, but it works. Sometimes you need duct tape to hold things together.

The Verdict: Are We There Yet?

So, are Web Components ready for prime-time SSR? Well… it’s complicated.

The good news: We’ve made it work. Stencil now supports SSR in React and Vue environments. You can build design systems with Web Components and render them on the server.

The reality check: It’s not seamless. Even lit.dev uses server-rendered Web Components sparingly. The overhead, complexity, and edge cases mean that for many applications, traditional framework components still make more sense.

Web Components shine in environments where SSR doesn’t matter — Chrome’s internal pages, Electron apps, VS Code extensions. But for your typical Next.js e-commerce site? The jury’s still out.

The Journey Continues

The inability to elegantly handle SSR remains one of the biggest barriers to Web Components adoption. We’re making progress, but there’s still a mountain to climb.

The web platform is evolving. Proposals like Declarative Custom Elements could eliminate many current pain points. Until then, we’ll keep pushing, experimenting, and occasionally pulling our hair out.

Because that’s what we do. We’re developers. We solve problems, even when those problems involve making fish climb trees.

Thanks for joining me on this journey through the SSR wilderness. May your hydration errors be few and your bundle sizes small.


About the Author: A Stencil framework developer who has spent way too much time thinking about Shadow DOMs and server environments. When not wrestling with SSR, can be found explaining why Web Components aren’t dead yet.


Stencil Team