But one serious shortcoming of Web Components is their current lack of support for server-side rendering (SSR). There is something called the Declarative Shadow DOM (DSD) in the works, but current support for it is pretty minimal, and it actually requires buy-in from your web server to emit special markup for the DSD. There’s currently work being done for Next.js that I look forward to seeing. But for this post, we’ll look at how to manage Web Components from any SSR framework, like Next.js, today.
We’ll wind up doing a non-trivial amount of manual work, and slightly hurting our page’s startup performance in the process. We’ll then look at how to minimize these performance costs. But make no mistake: this solution is not without tradeoffs, so don’t expect otherwise. Always measure and profile.
Before we dive in, let’s take a moment and actually explain the problem. Why don’t Web Components work well with server-side rendering?
Application frameworks like Next.js take React code and run it through an API to essentially “stringify” it, meaning it turns your components into plain HTML. So the React component tree will render on the server hosting the web app, and that HTML will be sent down with the rest of the web app’s HTML document to your user’s browser. Along with this HTML are some
<script> tags that load React, along with the code for all your React components. When a browser processes these
<script> tags, React will re-render the component tree, and match things up with the SSR’d HTML that was sent down. At this point, all of the effects will start running, the event handlers will wire up, and the state will actually… contain state. It’s at this point that the web app becomes interactive. The process of re-processing your component tree on the client, and wiring everything up is called hydration.
So, what does this have to do with Web Components? Well, when you render something, say the same Shoelace
<sl-tab-group> component we visited last time:
<sl-tab-group ref="tabsRef"> <sl-tab slot="nav" panel="general"> General </sl-tab> <sl-tab slot="nav" panel="custom"> Custom </sl-tab> <sl-tab slot="nav" panel="advanced"> Advanced </sl-tab> <sl-tab slot="nav" panel="disabled" disabled> Disabled </sl-tab> <sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel> <sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel> <sl-tab-panel name="advanced">This is the advanced tab panel.</sl-tab-panel> <sl-tab-panel name="disabled">This is a disabled tab panel.</sl-tab-panel> </sl-tab-group>
<sl-tab-xyz> tags to match the finished output, but this is all but impossible in practice, especially for a third-party component library like Shoelace.
Moving our Web Component registration code
So the problem is that the code to make Web Components do what they need to do won’t actually run until hydration occurs. For this post, we’ll look at running that code sooner; immediately, in fact. We’ll look at custom bundling our Web Component code, and manually adding a script directly to our document’s
In our case, we’re just looking to run our Web Component registration code in a blocking script. This code isn’t huge, and we’ll look to significantly lessen the performance hit by adding some cache headers to help with subsequent visits. This isn’t a perfect solution. The first time a user browses your page will always block while that script file is loaded. Subsequent visits will cache nicely, but this tradeoff might not be feasible for you — e-commerce, anyone? Anyway, profile, measure, and make the right decision for your app. Besides, in the future it’s entirely possible Next.js will fully support DSD and Web Components.
All of the code we’ll be looking at is in this GitHub repo and deployed here with Vercel. The web app renders some Shoelace components along with text that changes color and content upon hydration. You should be able to see the text change to “Hydrated,” with the Shoelace components already rendering properly.
Custom bundling Web Component code
import setDefaultAnimation from "@shoelace-style/shoelace/dist/utilities/animation-registry"; import "@shoelace-style/shoelace/dist/components/tab/tab.js"; import "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js"; import "@shoelace-style/shoelace/dist/components/tab-group/tab-group.js"; import "@shoelace-style/shoelace/dist/components/dialog/dialog.js"; setDefaultAnimation("dialog.show", keyframes: [ opacity: 0, transform: "translate3d(0px, -20px, 0px)" , opacity: 1, transform: "translate3d(0px, 0px, 0px)" , ], options: duration: 250, easing: "cubic-bezier(0.785, 0.135, 0.150, 0.860)" , ); setDefaultAnimation("dialog.hide", keyframes: [ opacity: 1, transform: "translate3d(0px, 0px, 0px)" , opacity: 0, transform: "translate3d(0px, 20px, 0px)" , ], options: duration: 250, easing: "cubic-bezier(0.785, 0.135, 0.150, 0.860)" , );
It loads the definitions for the
<sl-dialog> components, and overrides some default animations for the dialog. Simple enough. But the interesting piece here is getting this code into our application. We cannot simply
While Next.js does have a number of webpack hooks to custom bundle things, I’ll use Vite instead. First, install it with
npm i vite and then create a
vite.config.js file. Mine looks like this:
import defineConfig from "vite"; import path from "path"; export default defineConfig( build: outDir: path.join(__dirname, "./shoelace-dir"), lib: name: "shoelace", entry: "./src/shoelace-bundle.js", formats: ["umd"], fileName: () => "shoelace-bundle.js", , rollupOptions: output: entryFileNames: `[name]-[hash].js`, , , , );
This will build a bundle file with our Web Component definitions in the
shoelace-dir folder. Let’s move it over to the
const fs = require("fs"); const path = require("path"); const shoelaceOutputPath = path.join(process.cwd(), "shoelace-dir"); const publicShoelacePath = path.join(process.cwd(), "public", "shoelace"); const files = fs.readdirSync(shoelaceOutputPath); const shoelaceBundleFile = files.find(name => /^shoelace-bundle/.test(name)); fs.rmSync(publicShoelacePath, force: true, recursive: true ); fs.mkdirSync(publicShoelacePath, recursive: true ); fs.renameSync(path.join(shoelaceOutputPath, shoelaceBundleFile), path.join(publicShoelacePath, shoelaceBundleFile)); fs.rmSync(shoelaceOutputPath, force: true, recursive: true ); fs.writeFileSync(path.join(process.cwd(), "util", "shoelace-bundle-info.js"), `export const shoelacePath = "/shoelace/$shoelaceBundleFile";`);
Here’s a companion npm script:
"bundle-shoelace": "vite build && node util/process-shoelace-bundle",
That should work. For me,
util/shoelace-bundle-info.js now exists, and looks like this:
export const shoelacePath = "/shoelace/shoelace-bundle-a6f19317.js";
Loading the script
Let’s go into the Next.js
_document.js file and pull in the name of our Web Component bundle file:
import shoelacePath from "../util/shoelace-bundle-info";
Then we manually render a
<script> tag in the
<head>. Here’s what my entire
_document.js file looks like:
import Html, Head, Main, NextScript from "next/document"; import shoelacePath from "../util/shoelace-bundle-info"; export default function Document() return ( <Html> <Head> <script src=shoelacePath></script> </Head> <body> <Main /> <NextScript /> </body> </Html> );
And that should work! Our Shoelace registration will load in a blocking script and be available immediately as our page processes the initial HTML.
We could leave things as they are but let’s add caching for our Shoelace bundle. We’ll tell Next.js to make these Shoelace bundles cacheable by adding the following entry to our Next.js config file:
async headers() return [ source: "/shoelace/shoelace-bundle-:hash.js", headers: [ key: "Cache-Control", value: "public,max-age=31536000,immutable", , ], , ];
Now, on subsequent browses to our site, we see the Shoelace bundle caching nicely!
If our Shoelace bundle ever changes, the file name will change (via the
:hash portion from the source property above), the browser will find that it does not have that file cached, and will simply request it fresh from the network.
This may have seemed like a lot of manual work; and it was. It’s unfortunate Web Components don’t offer better out-of-the-box support for server-side rendering.
But we shouldn’t forget the benefits they provide: it’s nice being able to use quality UX components that aren’t tied to a specific framework. It’s aldo nice being able to experiment with brand new frameworks, like Solid, without needing to find (or hack together) some sort of tab, modal, autocomplete, or whatever component.