Server Side Rendering (SSR) is a very hot topic today. What’s with React Server Components bringing all these buzz words that “I just have to implement in my project”… Let’s see what’s all the fuss about by implementing our own SSR server for web components.
At Vonage we have a public project called Developer Portal. It’s a documentation website that’s not behind a login page (a.k.a. public) and holds mostly content. We also want the content to be Search Engines Optimized (SEO). This makes it a good candidate for SSR.
The developer portal is written in Vue and is served using Nuxt. Nuxt allows for SSR via its Universal Rendering mechanism. We needed to allow Nuxt to also SSR our design system’s web components. Hence started our journey of building an SSR mechanism for Web Components.
Table of Contents
What is SSR?
In a nutshell – SSR is the process in which we run our app on a server and return plain HTML to the client.
In our portal, the vue code is rendered on a nodejs (nuxt) server. The output of the rendering is HTML (with possibly inlined CSS). This HTML (+CSS) is sent to the browser and shown there – without any JavaScript. Hence, the user gets to see the website really fast.
In addition, showing the website’s layout as it should be with JavaScript avoids heavy layout shifts resulting from components suddenly getting content and expanding once JavaScript kicks in.
Note that bots (such as search engine crawlers) usually don’t see JavaScript, so getting this bunch of contentful HTML right away could do wonders for your search engine ranking.
The one caveat here is that without JavaScript, we have no functionality or interactivity. So, the user gets to see the website but not interact with non-native functionality. Forms, links, videos, etc., should work in non-complex examples.
Remember that the Developer Portal is mostly documentation? This is a classic example for when SSR is truly needed. Documentation is mostly just text and images shown to the user. The interactivity is mainly scrolling to see more of the text and images. You can call this “thin” view layer as the dehydrated
version of our application.
What if we need to interact with the page? We will need to hydrate
our components. Hydration
is a marketable name for “load our JavaScript”. Once JavaScript loads, we get our functionality.
So in essence, SSR helps us load our content faster so users can consume it – but not interact with it. It also contributes to our SEO ranking.
How to Build Your Own SSR Server?
The first thing I recommend to most people is: Don’t Build Your Own SSR Server.
Having said that, in this article we will build our own server to learn the mechanics behind the SSR paradigm and its possible extensions. Understanding how SSR works will help you extend current SSR solutions to fit your needs. For instance, you’d might find yourself in need to SSR web components in a nuxt
server.
Now that we understand the usefulness of building an SSR server (or lack thereof 😉 ), let’s build one for learning purposes.
An SSR server is essentially an HTTP server that receives a request from the client, and through this request parses a template and returns HTML to the client.
Here’s an illustration of the process:
From this we can define the building blocks of our server:
- An HTTP server that handles routes
- A rendering function
Setting Up the HTTP Server
The HTTP server is pretty simple:
The code is folded for readability purposes.
On line 13 we create the server.
On line 46 we set the server to listen on a port, so we can access the server in localhost:3000
The full code for the server is here:
The createServer callback handles the request. It gets the URL of the request and parses it.
If the extension’s name is one of CONTENT_TYPES
(defined in line 5), it just returns the file with the content type in the header (the logic of returnFileContent
defined in line 13).
In any other case, we return text/html
.
We return only ‘Hello World’, but we will change that momentarily.
A Simple Routing
According to our specification, the server needs to accept routes and handle them. The routes will be URLs like: localhost:3000/home-page
. We’ll use a simple hash to create our router.
In our project we’ll have a routes
folder that’s going to hold an index.mjs
file.
Beside it we will create a home-page
folder in which the homepage route will reside. It’ll look like this:
home-page
will hold its own index.mjs
file:
This HomePage object will also be exported from the routes/index.mjs
file:
Now we just need to implement getHomePageTemplate
in home-page.template.mjs
:
Finally, we need to use the route in our server, so we will change the main index.mjs file:
Here we import the routes (line 5) and use the routes when we return text/html (line 41).
The results are astounding!
Let’s Add a Better Template
This template is quite boring… let’s return something spicy. For this, I’ll use the Vivid design system. Vivid components are pure web components. We will use them to spice up our template and render them server side.
In Vivid’s button component page we can take the appearance example which exhibits four different buttons:
We can replace our template in home-page.template.mjs
: with the example code:
And the result here is:
A blank page beside a not-so-empty body. Where are the components from the code example?
They do not load because they require us to load JS and CSS.
How to Load CSS and JavaScript?
This is usually a trivial question – but how is it done in an SSR server?
Let’s go for the simplest way to do this by using a CDN. You can import Vivid components by using this convention:
https://unpkg.com/@vonage/vivid@latest/{pathToFile}
Using this, we can import our code in the template:
If we go test our client we will see our components. Well… kinda:
One thing we need to make Vivid
components to work is to add the vvd-root
class to the element that wraps the components (usually the body…).
Let’s define a wrapper to our template:
Here’s the outcome:
So the buttons work but… can you see the issue?
The HTML loads – as we can see from the wrapping div – and then the buttons render once the JS kicks in, creating a major layout shift. Imagine this happening in a bigger app with a lot more components.
How can we prevent this flash? Let’s render the components on the server!
Creating the Rendering Function
Instead of loading the JS on the client side, we can render the components on the server and send a complete HTML. So we need to find a way to render our components on the server as if they were in a browser.
Every framework has a different rendering method.
Web components are rendered natively by the browser. Web components also bring the idea of shadow DOM. In essence, the shadow DOM is a document fragment in which you can add HTML and CSS. For this, browser creates a shadow root inside our component:
Everything outside the shadow-root is “in the light” while the rest is in the shadow. The advantage of a shadowDOM is that it encapsulates the styles. Styles inside do not affect anything outside and (almost completely) vice-versa.
That means that if we take our template and set it as the innerHTML of a div, we should get rendered components. Let’s try that in the browser:
If you paste this code into your browser, you should see the crimson div without the button because the JS would not be imported.
Nevertheless – if you’d have imported the JS beforehand, it would have worked:
As tested on Google.com:
Thing is – document, body and HTML elements do not exist natively server-side. So…
How can you render HTML on a server?
Great question! Glad you asked.
There are several ways to render HTML server-side.
Because Vivid tests its components using jsdom, we know it can render our components without a browser.
Hence, if we create a JSDOM environment in our server, we can use our code to render our components.
That’s easy enough because of the almighty NPM!
npm i global-jsdom/register jsdom
will add jsdom
– a library that mocks the browser’s DOM API in the server runtime, allowing it to create markup as if it were in the browser. global-jsdom/register
exposes browser API globally so we can use it in our code. Hence, we can render our components serverside.
Let’s change our template’s code a bit to use that:
We import global-jsdom/register
. Note that we import the @vonage/vivid/button
package server-side, so the web component will be rendered as one.
We let jsdom
render our template just by adding it to the DOM and returning its innerHTML
. It looks like this:
OH NO! No buttons in the view! They are indeed in the DOM. We can also see the input in the light DOM inside every button (it’s there to solve form association).
The reason we do not see anything is that innerHTML
does not get us the content of the shadowDOM.
So what we could try doing is getting the shadowDOM
of every component like this:
function appendOwnShadow(element) {
const shadowTemplate = `${element.shadowRoot.innerHTML}`;
const tmpElement = document.createElement('div');
tmpElement.innerHTML = shadowTemplate;
element.appendChild(tmpElement.children[0]);
}
Array.from(div.querySelectorAll(‘vwc-button’))
.forEach(button => button.appendChild(appendOwnShadow(button)));
Which give us this UI:
Yay! We can see something but… it’s not exactly the same, right?
And looking at the HTML, we can see the shadowroot is missing:
This definitely might affect the component’s styling, since we are losing the encapsulation.
How to Explicitly Render Shadow DOM without JavaScript?
For this purpose, the HTML spec now defines a shadowrootmode
attribute for the template tag. When the browser encounters <template shadowrootmode=”open”>
it knows to take everything inside that template and render it inside a shadow DOM.
Using this knowledge, we can change our code as follows:
function appendOwnShadow(element) {
const shadowTemplate = `<template shadowrootmode="open"> ${element.shadowRoot.innerHTML}</template>`;
const tmpElement = document.createElement('div');
tmpElement.innerHTML = shadowTemplate;
element.appendChild(tmpElement.children[0]);
}
Array.from(div.querySelectorAll(‘vwc-button’))
.forEach(button => button.appendChild(appendOwnShadow(button)));
It now renders like this:
Which is how we expected it to render! Hooray!
If you look at the DOM now, it looks like this:
How cool is that? We rendered our web components server-side and prevented the layout shift in our app!
Let’s try to spice up our application.
Handling Complex Components
The button we used was quite basic. Let’s try to use a button with an icon inside:
And it looks like this in the browser:
Something changed, but we can’t see any icons…
The HTML inside the button looks like this:
We can see vwc-icon
right there in the middle. We can see two problems here:
- The icon has no attributes – so it doesn’t really know how to render itself.
- The icon has no content – mainly, no shadowroot
Solving the Icon not Getting Attributes
Let’s solve the simpler issue. The icon gets its attributes from the button component. The template is rendered asynchronously. That means that after we add the div
to the DOM, the actual update happens after another iteration of the event loop. So, we need to await the completion of the rendering process.
For this, we can set the template function to be async and await one event loop cycle:
Notice we’ve added the magic await new Promise(res => setTimeout(res));
in line 28. Because we changed the template method to be async, we also need to change our server function to be async and await the template:
Now when we glimpse at our HTML we see the icon gets the attributes:
Loading Internal Components
The second issue – because of which we do not see the icons – arise from the fact we do not get the shadowroot’s HTML of the internal components.
One way to fix this would be to find all the web components recursively and render them as well.
To find the components, we can traverse the DOM tree like this:
This function gets an element (supposedly our wrapping div) and gets all the web components with shadowDOM.
Now, all that’s left to do is parse each one of them in our template file:
Let’s do that:
Notice the change in line 54 – we’re going over all the elements with shadow DOM in reverse order and append a shadowroot
template with their innerHTML for each of them.
The result is astounding:
If you followed so far – good job! You got the basics of SSR.
Can We Serve More?
Our simple SSR server can be optimized further. For instance, some things, such as the CSS and the icons’ SVGs, are still dependent on servers far away. We can add more logic to our SSR server to fetch them and inline them in the returned HTML.
More ideas can be taken from other SSR systems. For instance, react server components have a dedicated API to fetch and send requests to the server, which in turn requests the data and renders the needed view.
Qwik sets up service workers to fetch the JS in the background.
All of the SSR frameworks have many optimizations done for you, but they do not always fit your needs, so knowing how they work is a good start to extending them.
Summary
That was quite a ride, wasn’t it?
Building an SSR mechanism is quite simple in essence, but it can always be improved, tweaked, and optimized. You might possibly find yourself maintaining a big codebase just to handle SSR.
You can choose to use nextjs (react), nuxtjs (vue) or some other SSR library. If you are using web components, SSR libraries like litssr or fastssr can take the heavy lifting from you.
One big caveat with these SSR frameworks or libraries is that they work only for the framework or library they were meant to work with.
Our use case was to build an SSR mechanism to work alongside nuxt
. So you can call my code an SSR plugin. I hope this article gave you a hint on how to get started building a plugin like that if the need ever arises.
The commonality to all SSRs is that there is some rendering function. This function is used on your template and returns an HTML string that is sent to the client (well, except React Server Components that actually send a JSON – but that’s beyond the scope of this article).
Some of this HTML is hydrated
later on after the JavaScript loads asynchronously, without blocking the page. In this article, we learned how to do it with web components and shadow DOM.
The fact we do not block the page with JS load helps us serve content faster, avoid heavy layout shifts, and possibly enhance our SEO ranking.
Thanks a lot to Evyatar Alush, the Author of Vest, for the kind and thorough review of this article.