< Back
calendar

Apr 27, 2023

How To Server-Side Render A Web Component

Blazing fast lazy-loaded Web Components

Header for posting How To Server-Side Render A Web Component
Photo by Xavi Cabrera on Unsplash

Why server-side rendering?

Server-side rendering means the server sends a complete HTML document to the browser which then renders it. This is in contrast to client-side rendering where a partial HTML page is sent to the browser and then JavaScript renders the rest in the browser.

When a browser requests an HTML page, it’s often changed by JavaScript before it’s shown to the user. This means that the HTML that is sent by the server is not always the same as what the user sees. This is especially true for Single Page Apps (SPA) and JavaScript frameworks that enable developers to write components.

In the case of a SPA, only the so-called “skeleton” HTML is sent to the browser. This skeleton is the layout that is used on all pages and usually consists of only the header and footer. For each page, dynamic content is fetched which is then placed between the header and footer.

When a user then navigates to another page, only the dynamic content is fetched and also placed between the header and footer, replacing any content that was already present. While this enables very fast page navigations because only the dynamic content is replaced, it does mean that the very first page load is slower because after the skeleton is loaded, the dynamic content needs to be fetched separately.

This is also bad for Search Engine Optimization (SEO) because search engine crawlers usually don’t execute JavaScript so they will only “see” the skeleton and not the dynamic content which will now not be indexed.

Server-side rendering can solve both problems by rendering a full page for the first visit of a user (header + dynamic content + footer). This first page will now be loaded faster and any subsequent navigations will be handled by replacing the dynamic content only. Search engine crawlers will always be served a full page so all content can be indexed.

In the case of JavaScript frameworks that enable the creation of components, these components are always rendered in the browser using JavaScript. The page HTML usually only contains a single HTML tag for the component which will be rendered to the component’s full HTML in the browser. By doing this rendering already on the server, the component’s HTML will already be part of the webpage so it will be immediately visible, even before its JavaScript code has been loaded.

For Custom Elements, this should work in the same way, but this doesn’t work when using Shadow DOM because there’s no way to represent a Shadow Root in plain HTML: you cannot write Shadow DOM directly into an HTML document. To make Shadow DOM part of the webpage it would need to be added declaratively: you write (declare) it as plain HTML which is part of the webpage.

But unfortunately, the only way to create a Shadow Root is imperatively: you write JavaScript code that instructs the browser to create it.

Until now.

Declarative Shadow DOM

Declarative Shadow DOM enables developers to write the HTML for the Shadow DOM directly in the document. It is added to a Custom Element using a <template> element with a shadowrootmode attribute.

The `shadowrootmode` attribute used to be `shadowroot`. Some older browsers still only support the latter.

When the HTML parser encounters such a <template> it is immediately converted and added as a Shadow Root to its parent element:

<html>
<head></head>
<body>

<my-element>
<template shadowrootmode="open">
<style>
h1 {
color: red;
}

::slotted(p) {
color: green;
}
</style>

<h1>Declarative Shadow DOM</h1>
<slot></slot>
</template>

<p>This is a light DOM</p>
</my-element>

</body>
</html>

In supporting browsers, you will see red heading text and a paragraph in green text:

The beauty of this approach is that the Shadow root will be attached to the Custom Element and rendered with its internal CSS applied without any JavaScript.

Note that the <template> element is replaced by its content.

In non-supporting browsers, you will only see the light DOM which in this case is the <p> tag containing the text “This is light DOM”. This is a potential issue in non-supporting browsers because it can mean that unstyled content will be shown.

This can be avoided by combining the :not() and :defined CSS pseudo-classes to hide my-element while it hasn’t been upgraded to a Custom Element yet:

my-element:not(:defined) {
display: none;
}

This ensures my-element is not visible until it is defined, but since this will also apply in browsers that do support Declarative Shadow DOM, it won’t be visible there either. Fortunately, you can take advantage of the fact that supporting browsers replace the <template> with its content, while non-supporting browsers will not.

The CSS rule can now be expanded to target the Custom Elements that do have a <template> with a shadowrootmode attribute and hide the content that comes after it (the light DOM):

my-element:not(:defined) > template[shadowrootmode] ~ *  {
display: none;
}

This rule targets the <template shadowrootmode="..."> inside <my-element> and uses the general sibling selector ~to target any content (*) that comes after it.

The following example will show the declarative Shadow DOM of the element in supporting browsers and nothing in non-supporting browsers:

Serialization

At this point, you might wonder if you would have to convert all your component to use declarative Shadow DOM if you need server-side rendering. You probably also think to yourself that this defeats the purpose of Shadow DOM since now you need to put the Shadow DOM in the tag of your component itself and all encapsulation is lost.

What’s the use of reusable Custom Elements when every time you want to reuse it, you need to write the whole Shadow Root instead of just the HTML tag?

Don’t worry, you won’t have to.

You will use your Custom Elements by just using their tags. Whenever you need server-side rendering, you will use a build task that will convert these to components that contain a declarative Shadow Root. For this purpose, the getInnerHTML() method has been added to HTMLElement.

This method works just like the innerHTML property but provides an option to specify if the Shadow Root should be returned as well:

element.getInnerHTML({includeShadowRoots: false}); // will not return the Shadow Root

element.getInnerHTML({includeShadowRoots: true}); // will include the Shadow Root

element.getInnerHTML(); // will include the Shadow Root

Note that includeShadowRoots: true is the default so without this option the Shadow Root will be returned. You need to explicitly specify includeShadowRoots: false to exclude it from the result. When the Shadow Root is excluded any light DOM will still be returned.

A build task can now be executed on the server that selects all Custom Elements on the page, calls getInnerHTML() on each element, and then replaces its content (innerHTML) with the returned value.

When getInnerHTML() is called on a Custom Element that has an imperative Shadow Root (added with JavaScript) it will be returned conveniently wrapped in a <template shadowroot="open"> element:

<my-element>
<p>This is an imperative Shadow Root</p>
</my-element>
class MyElement extends HTMLElement {
constructor() {
super();

const shadowRoot = this.attachShadow({mode: 'open'});

shadowRoot.innerHTML = `
<h1>Imperative Shadow DOM</h1>
<slot></slot>
`;
}
}

MyElement.getInnerHTML();

will return:

<p>This is an imperative Shadow Root</p>
<template shadowrootmode="open">
<h1>Imperative Shadow DOM</h1>
<slot></slot>
</template>

even though this element does NOT have a declarative Shadow Root!

Conclusion

Declarative Shadow DOM enables us to attach a Shadow root to a Custom Element and fully render it without any JavaScript. This is a huge step for web components since they can now be rendered on the server. But there is a slight problem though.

Our component doesn’t do anything, it’s not interactive.

If you check the server-side rendered components from the previous examples in the browser’s dev tools you will notice they all have a Shadow root attached to it. But when you check the CustomElementsRegistry to find the constructor of the element:

const el = await customElements.get('my-element');

console.log(el) // undefined 😱

you will notice that it hasn’t even been registered as a Custom Element.

This is an important fact to realise about Declarative Shadow DOM: it only attaches a Shadow root to an element.

In other words, it only takes care of rendering the HTML of the component and nothing else. The benefit of this approach is that it enables web components to be rendered very fast, but after that, they still need to be registered as Custom Elements and be made interactive.

That is what hydration does. In my next article I explain you how to hydrate a server-side rendered Web Component.


Join Modern Web Weekly, my weekly update on the modern web platform, web components, and Progressive Web Apps delivered straight to your inbox.