This post is outdated. Check out the latest, greatest way to server-side render Web Components in this post: Server Side Rendering a Blog with Web Components.
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 = '';
this.rootElement.appendChild(component);
}
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.
require('@skatejs/ssr/register');
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, '');
res.send(index);
})
} else {
res.send(dom);
}
}
The example is missing some logic like redirecting when a route doesn't exist. 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.
@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.
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. 😎
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.
Top comments (15)
That's not what SSR means. SSR means that server-rendered HTML gets hydrated, without modification, to add interactivity. You're rendering a server-rendered page and then blowing it away and replacing it with a client-rendered page, undermining your performance gains.
P.S. I pulled down the feature/router branch and ran it with JS disabled. The page is blank. This isn't SSR… not even close.
Server side rendering means generating html on the server, that is all. What you are describing is how server-side rendering works in React, but how server-side rendering is handled in React does not define how server-side rendering is done everywhere.
The Lighthouse score on my personal site which uses a modified version of this SSR technique would disagree with your performance assessment. The site only takes a slight hit to performance because of the cheap server the site is hosted on, otherwise it would be 100%.
When you pulled down the branch, did you also run the node server? Something sounds amiss.
Pulled down the feature/router branch and noticed a bug with Parcel. I also updated the example so it should work out of the box now. Example is a year old, just needed a little maintenance. There seems to be some issues with styling, but that could be fixed.
web.dev/declarative-shadow-dom/ @steveblue
dev-to-uploads.s3.amazonaws.com/up...
Yes, Declarative Shadow DOM is still only available in Chrome AFAIK.
Although if I were to SSR WC today I would use Declarative Shadow DOM and tooling provided by Lit, polyfill the rest of the browsers.
New blog post here uses @lit-labs/ssr instead, way better solution IMHO.
dev.to/steveblue/server-side-rende...
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.
Superb article , i use github.com/popeindustries/lit-html... to ssr webcomponents , cleanly works on all environments browser, server even sw.
Thanks @steve for a great writeup.
I learned a lot. Thank you.
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?
Lately Lit SSR is achieving new ways to do SSR:
lit.dev/docs/ssr/overview/
Yeah, I've been meaning to make a follow up blog post. I also have a workshop in the works for SSR with @lit-labs/ssr.