Cover image for Server Side Render Web Components

Server Side Render Web Components

steveblue profile image Steve Belovarich Updated on ・5 min read

It's a common myth that you can't server side render Web Components. Turns out you can if you look in the right place. You'd think a myriad of tools could handle rendering a custom element on the server, but that's not the case. Sounds odd, seeing as custom elements are spec while JavaScript frameworks are not. Some engineers have said the task is impossible, listing lack of SSR as a reason to avoid Web Components altogether.

It may seem like a no brainer that SSR packages would support Web Components because custom elements are spec. While some server side rendering tools may tout support for custom elements, the contents of ShadowDOM are often missing when the view is delivered to the client. This may lead to some of the confusion. It’s a lot to ask, because it means ShadowDOM gets special treatment for the purposes of server side rendering. JSDOM added support for custom elements last week, closing a ticket that had been open for five long years on Github. Unfortunately I couldn't figure out how to expose Shadow DOM with the latest version of JSDOM.

@skatejs is a set of tools for developing Web Components that has been around for a few years. The @skatejs/ssr package can render ShadowDOM on the server. They accomplish this feat by extending undom. The awesome part about @skatejs/ssr is you don't have to code custom elements with @skatejs in order to leverage server side rendering. You can use whatever you like. I’m coding custom elements with a library called Readymade.

With only a few lines of code in node.js I was able to render custom elements with ShadowDOM. In this post I outlined my process so others can take advantage of server side rendering Web Components.

It all starts with a client side router. I needed a router so I could map a custom element and it’s template to a page. This would enable me to do the same thing on the server. I chose @vaadin/router first because its compatible with Web Components and I liked the API. I quickly found out this package wasn't compatible with server side rendering out of the box. An odd issue occurs that causes the same element to display twice on the page, likely caused by the router appending DOM to the container element rather than overwriting it. I hardly expected hydration, but figured maybe it would work.

Instead I ended up coding a simple client router that uses history and location to display a custom element per route. It's very bare bones, but does the job for now. View the code here. Implementing the custom router inside of an application that uses custom elements looks like this:

import { RdRouter } from './router/index';

const routing = [
    { path: '/', component: 'app-home' },
    { path: '/about', component: 'app-about' }

const rdrouter = new RdRouter('#root', routing);

In the above example two routes are mapped to the tag names of two custom elements: app-home and app-about. Both custom elements will be rendered in the div with the id root.

resolve(route: RdRoute) {
    const component = document.createElement(route.component);
    this.rootElement.innerHTML = '';

Once routing was in place, I had to figure out what the @skatejs/ssr package expected to render. All the examples I found showed the custom element's ES2015 class being passed into the render method.

I was already bundling my application with Parcel. I needed a way to bundle just the view components tied to each route so I could pass each one to the @skatejs/ssr render method in node.js. Each “view” contains a template encapsulated by ShadowDOM. That template contains all the elements on the page. I chose to bundle the custom elements with Rollup prior to the production build and then import the source code for each into the file that contains the middleware.

I wanted to dynamically render each view. In the new bundle I exported a simple config for the node.js middleware to interpret.

const routes = [
    { path: '/', component: HomeComponent },
    { path: '/about', component: AboutComponent }

export { routes };

Usually for a single page application you would serve up the index.html on every request, but since we're server side rendering now, we have to create some middleware to handle the same requests. Instead of the static html, the server will respond with the server side generated Web Components.

import ssr from "./middleware/ssr";

// app.get("/*", (req, res) => {
//   res.sendFile(path.resolve(process.cwd(), "dist", "client", "index.html"));
// });

app.get("/*", ssr);

The middleware is actually quite simple compared to JS frameworks. Parcel handles bundling and optimization in my project, so in this middleware I read the index.html Parcel compiled. The server code sits in a sibling directory to the client. After importing the JavaScript that makes up the view, I call render, pass the resulting template into the HTML of the index template, and send off the response to the client with the server side rendered custom elements.

const render = require('@skatejs/ssr');

const url = require("url");
const path = require("path");
const fs = require("fs");

const { routes } = require('./../view/index.js');

const indexPath = path.resolve(process.cwd(), "dist", "client", "index.html");
const dom = fs.readFileSync(indexPath).toString();

export default async (req, res) => {
    let template = class {};
    template = routes.find(route => route.path === url.parse(req.url).pathname).component;
    if (template) {
        render(new template()).then((tmpl) => {
            const index = dom.replace(`<div id="root"></div>`, `<div id="root">${tmpl}</div>`)
                              .replace(/__ssr\(\)/g, '');
    } else {


The example is missing some logic like redirecting when a route doesn't exist. That's alright! This is a simple proof of concept. For some reason the @skatejs/ssr package kept inserting a call to a __ssr function that doesn't exist on the client, so I had to wipe it out before the template is sent to the client otherwise the browser reports an error.

The rendered Web Component is inserted into the same DOM node the client side router injects the custom element.

Alt Text

@skatejs/ssr does something kinda quirky and wraps the Shadow DOM content in a shadowroot tag.

That's alright, because the client side router kicks in immediately, replaces the element in the same container and renders the appropriate shadow-root in DOM.

Alt Text

Lately I've been developing some starter code for building apps with Readymade, a micro library for Web Components. That prompted me to figure out how to implement routing and server-side rendering with Web Components. It's 2020 and I was hoping to pull some packages off the shelf to get the job done, however I had to implement a client side router to make it work seamlessly. Maybe I could have used the sk-router package but upon first inspection I wasn’t impressed by its semblance to react-router. That's OK. I have wanted to figure out how to implement a router with vanilla JS for awhile. There are also some quirks to rendering custom elements with ShadowDOM but it is possible, contrary to popular opinion.

I just love being told something can't be done. 😎

Source code is here.

If you have found another way to render ShadowDOM server side or have any insights or questions about server side rendering Web Components please share in the comments below.

Posted on by:

steveblue profile

Steve Belovarich


full stack web engineer, creative coder, teacher, cultural critic and indie music fan.


Editor guide

Wonderful article @steveblue . I am trying to get this working with LitElement but I'm unable to pre-render the shadow DOM. Do you think it is possible to achieve that?


Thanks @steve for a great writeup.


why styles are [object Object]?

doesn't look right


Good catch! That's a bug in the library I used for WC Readymade. It can be replicated when there are no styles specified. Not a bug in SSR. Styles are rendered fine. Maybe I’ll update the example in the near future to demo styling too.


Updated with fixed example.