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

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:
- Parses this JavaScript into an AST
- Identifies which components need SSR
- Analyzes the props being passed
- Calls our hydrate module to pre-render the component
- 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:
- Component Analysis: When the server encounters a Stencil component, we intercept it
- Prop Serialization: We use serializeProperty to handle all props, including complex objects
- Children Transformation: Here’s where it gets interesting — we attempt to transform the React children into a string using react-dom/server
- Async Rendering: We call Stencil’s renderToString (which returns a Promise) right there on the server
- 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.