Isomorphic Web Components
Web components might be great, if only you could render them on the server.
Or can you? The lack of server-side rendering has become a sort of folk belief that oft goes unquestioned, and many people form opinions based on this (alleged) missing feature.
Well, I am happy to report that the fears are unfounded: you can absolutely server-side render a web component. But there are a few different ways it can go down.
The Current Landscape
Let’s start from the top.
The building blocks of web components — template elements, custom elements and (declarative) shadow DOM — are all just HTML tags.
So from a pedantic point of view, server-side rendering a web component is trivial: just put a <template>
or a <custom-element>
tag in your markup.1
I’m being glib, but this already genuinely powerful! Custom elements let you attach logic to specific points in the light DOM. Rather than declaring that attachment point in a separate JavaScript file, though, you can do it directly in your HTML markup Blinded By the Light DOM I only recently had a breakthrough about using web components, and now I quite like them. But not the shadow kind. meyerweb.com/eric/thoughts/2023/11/01/blinded-by-the-light-dom/ . This strategy — using custom elements without templates or shadow DOM, to enhance light DOM elements that already exist — has come to be called HTML web components HTML web components Don’t replace. Augment. adactio.com/journal/20618 .
You don’t even need to bring in JavaScript for web components to be useful. Hawk Ticehurst shared a pattern he calls CSS web components CSS Web Components for marketing sites The truly No JavaScript web component. hawkticehurst.com/2024/11/css-web-components-for-marketing-sites/ , in which custom element attributes are used as hooks for CSS selectors. This gives us a props-like API to modify a component’s appearance without writing a byte of JavaScript.
None of this is what people usually mean, though.
“Web component” is really just an umbrella term for those three APIs "Web components" considered harmful "Web components" (the term) can be inaccurate, misleading, and sure, harmful. mayank.co/blog/web-components-considered-harmful/ , but in practice people use it to mean adding client-side behavior to custom elements by subclassing HTMLElement
.
And when they talk about server-side rendering web components, they mean running that subclass on the server and having it spit out the markup it would generate on the client.
This approach — running the same code in two different environments — is popularly called isomorphism. It’s how most major JavaScript frameworks approach server-side rendering. But for whatever reason, resources for doing it with web components are few and far between.
Is isomorphism purely a JavaScript framework thing, or is there a more standard way to do it?
If you open the HTML custom elements HTML Standard html.spec.whatwg.org/multipage/custom-elements.html spec and search for the word “server”, you’ll get two results and both of them are about form processing. You won’t find it at all in the DOM spec DOM Standard dom.spec.whatwg.org .
You might be thinking, “wait a minute — isn’t declarative shadow DOM a spec for server-side rendering web components?” The answer to that is: not really. Declarative shadow DOM defines a way to set up shadow roots within HTML (i.e. without JavaScript).
But many web components don’t use shadow DOM at all — they render plain old light DOM. And while the spec details how HTML is parsed into a shadow root, it’s agnostic as to how that HTML gets generated.
That makes sense, though! At the risk of stating the obvious: browser specs are written for the browser. Their concern is how the browser interprets the HTML it receives; how that HTML is created is outside of their purview.
Okay, no official guidance from the W3C. Now what?
At this point, libraries like Lit Lit Simple. Fast. Web Components. lit.dev , WebC WebC A docs page for Eleventy, a simpler static site generator. www.11ty.dev/docs/languages/webc/ and Enhance Enhance The HTML first full stack web framework enhance.dev enter the discussion. These are tools that target web components as an output format: you use them to build a component, and at the end you get a custom element that you can use in any website or web app. Many of them also let you render that component to static HTML on the server. Case closed, right?
Not quite. The code you write in these libraries might not look much like “vanilla” web components at all. Here’s an example of an Enhance element:
export default function MyElement({ html, state }) {
const { attrs } = state;
const { name } = attrs;
return html`
<p>Hello ${name}!</p>
<style>
p {
color: rebeccapurple;
}
</style>
`;
}
You’d be forgiven for thinking you were looking at a React component! Frankly, I don’t see a fundamental difference between these and frameworks like Svelte or Vue, which can also use web components as a compile target.2
To be clear, I don’t mean any of this as a slight. These are all good tools — web component libraries and JavaScript frameworks both. Ultimately, they all:
- Let you build server-side renderable web components, if you…
- Use their custom APIs and/or languages, and…
- Include them as a dependency in your project.
That makes me uneasy. When I choose to write a web component rather than, say, a Svelte component, one of my main reasons is to work directly with the web platform. I don’t want to add a build step or change how I write my code.
One of the coolest things about web components is that they act as a decoupling layer. It doesn’t matter whether a component is built with Enhance, Lit or anything else; I can drop it into a Svelte app or an Astro site or a Markdown file or a page of handwritten HTML and it will Just Work and I will be none the wiser Web Components Eliminate JavaScript Framework Lock-in | jakelazaroff.com Web components can dramatically loosen the coupling of JavaScript frameworks. To prove it, we're going to do something kinda crazy: build an app where every single component is written in a different JavaScript framework. jakelazaroff.com/words/web-components-eliminate-javascript-framework-lock-in/ .
Which is why I’m not super thrilled about server-side rendering solutions that are tied to particular libraries. The interoperability promise is broken — or, at the very least, weakened. How a component is built is no longer simply an implementation detail. What I’ve chosen for the frontend now exerts influence on the backend, and vice versa.3
So the goal is to take existing web components and render them server-side:
- Start with a web component that already works in the browser.
- This component will stand the test of time! No dependencies to update, no servers to maintain, no build processes that might inexplicably stop working. Sure, there are reasons why JavaScript might break Everyone has JavaScript, right? www.kryogenix.org/code/browser/everyonehasjs.html , but generally speaking this component will reliably work forever.
- Render that component into the HTML delivered to the browser. This introduces a dependency and a build process that can break, but that’s okay — if worse comes to worst, the component can still work with only client-side rendering.
This is kind of like a bizarro progressive enhancement. Rather than starting with HTML and enhancing it with JavaScript, we’re starting with JavaScript and enhancing it with HTML.
Note that this is not mutually exclusive with traditional progressive enhancement! With this strategy, the component’s server-side rendered HTML can still deliver baseline functionality even if the JavaScript fails to load. The fact that the same code that generated the HTML later ends up running in the browser is an implementation detail.
Curiously, there doesn’t seem to be much written online about this approach. If people are doing it, they’re not really talking about it. That’s why I decided to build a proof of concept.
Isomorphic Rendering
We’ll start by trying to server-side render this web component:4
customElements.define("greet-person", class extends HTMLElement {
connectedCallback() {
const name = this.getAttribute("name");
this.innerHTML = `<p>Hello, ${name}!</p>`;
}
});
We want to be able to write this in our HTML:
<greet-person name="Jake"></greet-person>
…and have it expand into this:
<greet-person name="Jake">
<p>Hello, Jake!</p>
</greet-person>
If we’re going to take any web component that works on the client, that means we’ll need a way to emulate the DOM. There are a bunch of libraries that do this, but we’ll use one called Happy DOM happy-dom Happy DOM is a JavaScript implementation of a web browser without its graphical user interface. It includes many web standards from WHATWG DOM and HTML.. Latest version: 15.11.7, last published: 14 days ago. Start using happy-dom in your project by running `npm i happy-dom`. There are 128 other projects in the npm registry using happy-dom. www.npmjs.com/package/happy-dom .
With Happy DOM in our toolbelt, the code to actually do the rendering is pretty short:
import { Window } from "happy-dom";
const globals = new Window();
global.document = globals.document;
global.customElements = globals.customElements;
global.HTMLElement = globals.HTMLElement;
export async function render(html: string, imports: Array<() => Promise<void>> = []) {
await Promise.all(imports.map((init) => init()));
document.documentElement.innerHTML = html;
return document.documentElement.getHTML({ serializableShadowRoots: true });
}
At the module’s top level, we create a Happy DOM Window
.
Just like in a browser, an instance of Window
contains all the global variables available — HTMLElement
, customElements
, you name it.
We’ll take these global variables and set them on Node’s global
object, which makes them available to all modules we might import.5
We only need to define one function to make this work.
We’ll call it render
, and it’ll take two props: the HTML to render as a string, and an array of functions that import the web component classes.
After awaiting the return value of each of those functions, we set the window’s documentElement.innerHTML
to the string we passed into the render
function, then serialize the emulated DOM to an HTML string and return it.
We call the render
function like this:
import { render } from "./render.js";
const html = `<!doctype html>
<html lang="en">
<body>
<greet-person name="Jake"></greet-person>
</body>
</html>
`;
const result = await render(html, [
() => import("./greet-person.js")
]);
The important part here is that we need to import the render
function before we import the web components.
That way, by the time the web components are declared, all the browser APIs they rely on are already available on Node’s global scope.6
That works for web components that stay within the light DOM. What about the shadow DOM?
Let’s update our component:
customElements.define("greet-person", class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open", serializable: true });
this.shadowRoot.innerHTML = "<p>Hello, <slot></slot>!</p>";
}
});
If you’ve used shadow DOM before, this probably looks familiar.
One thing that may be new to you — or at least, it was to me — is the serializable
ShadowRoot: serializable property - Web APIs | MDN The serializable read-only property of the ShadowRoot interface returns true if the shadow root is serializable. developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/serializable property, which instructs the element to render the shadow root into HTML.
Now, if we put this in our markup:
<greet-person><span>Jake</span></greet-person>
…it’ll expand into this:
<greet-person>
<template shadowrootmode="open" shadowrootserializable="">
<p>Hello, <slot></slot></p>
</template>
<span>Jake</span>
</greet-person>
There you go: isomorphic web components.
Conclusion
Any way you cut it, you can server-side render web components today:
- No special tooling is required to use templates, custom elements and declarative shadow DOM
- Web component libraries like Lit, Enhance and WebC let you write code that can both be server-side rendered and compiled to client-side web components
- Emulating the DOM lets you write isomorphic web components that work both on the server and in the browser
Smart people can disagree about the best approach, but I’m partial to isomorphic rendering. It works with all web components, no matter how they’re written. It fully embraces web platform APIs, rather than treating them as a compile target. And it makes our components resilient to toolchain entropy by gracefully degrading to client-side rendering.
Even if you don’t agree with me, though, there’s a server-side rendering solution out there for you. That’s the nice thing about the web: it’s flexible like that.
Footnotes
-
Imagine if I just ended the article there? “Yes you can, just use a
<template>
, next question.” ↩ -
Granted, the web component libraries approach web components from a place of respect, whereas the feelings of JavaScript framework authors seem to range from “mild annoyance” to “seething hatred”. ↩
-
Enhance takes an interesting stance here and uses WASM to decouple rendering from the programming language Enhance WASM — Enhance The HTML first full stack web framework enhance.dev/wasm . You still need to write your components in Enhance’s format, but then you can use them in any stack you want. ↩
-
There are better ways to define a web component To define custom elements or not when distributing them – Nathan Knowler Why not both? knowler.dev/blog/to-define-custom-elements-or-not-when-distributing-them than just throwing it in
customElements.define
, but we’ll go with this for brevity. ↩ -
If your component depends on more globals than
customElements
andHTMLElement
, you’ll also need to set those on theglobal
object. ↩ -
render
accepts an array of functions that will dynamically import the web component files for convenience, but you could omit that parameter and just ensure that you imported the web components after therender
function. ↩