Progressive enhancement options for traditional server-rendered sites

What’s the best way to progressively enhance a traditional server-rendered application with client-side JavaScript in 2023? Despite their glacial adoption, web components remain the closest we have to a platform-native solution.

Published
Last updated
Photo of Jay in Bazel

I’m a software engineer living and working in East London. I’m currently helping to build a one-stop-shop for the digitisation of alternative assets over at Daphne. Although once strictly front-end, today I work across the whole stack, including dipping my toes into DevOps and writing  Rust & Go.

A combination of several web component library logos

I’m currently building out a classic, server-rendered app, written in Rust. Requests come in, data is fetched, and Jinja templates (actually MiniJinja) are processed and sent back down the wire.

It’s refreshing to get back to such a simple request/response model, where the boundaries between server and client are clear-cut. Yet when it comes time to sprinkle on some interactivity — a mobile menu here, a lightbox there — picking a direction can be overwhelming.

Pushback against React and JS-heavy frameworks has led to somewhat of a renaissance for lighter-weight alternatives. There are a variety of flavours:

  • React-like alternatives, such as Svelte, Preact and friends. Getting the most out of these can involve a commitment, since they work best when they own state, rendering and the component tree. [1]
  • JavaScript-fist web component libraries, which want to imitate the React experience by providing an isomorphic render() method and a composition model (usually via the Shadow DOM). Google’s LitElement is the most popular choice here.
  • Web component helper libraries, which presume (or at least cater the most to) an existing server-rendered light-DOM. GitHub’s Catalyst is a good example.
  • Low (or no) JS attribute-based libraries which help remove the need to write any JS at all. These usually don’t use literal web components. Stimulus, Alpine.js and htmx are good examples.

Picking a side

For my specific project, the requirements were:

  • To have the core functionality (sans-enhancement) work without JS. This probably means forgoing the Shadow DOM, for reasons I’ll touch on.
  • To use my existing server-rendered templating/components.
  • To stick closely to web standards, for easy ejection at a future date.

The desire to keep a light touch (and retain the existing component templating) takes React-like frameworks off the table, and makes web components particularly appealing. Let’s have a quick run through of the remaining options.

The integrated experience: LitElement

Lit is one of the more popular SSR-friendly web component libraries (SSR support has been stuck on ‘experimental’ for a while, but seems to work well enough).

A component looks like this:

Logo for TypeScriptTypeScript
@customElement('simple-greeting') export class SimpleGreeting extends LitElement { // Declare reactive properties @property() name?: string = 'World'; // Render the UI as a function of component state render() { return html`<p>Hello, ${this.name}!</p>`; } }

The render() method will look familiar to anyone who ever wrote a class-based React component. Having Lit manage the component’s DOM and render lifecycle gives it very similar ergonomics to traditional JS view-frameworks. Server-side, render() generates the initial markup, while subsequent calls happen on the client.

Lit is also Shadow DOM first. Part of the rationale is style encapsulation, but most importantly it allows for greater encapsulation thanks to <slot>, which isn’t supported in the light DOM. The declarative shadow DOM — which provides a markup-based alternative to the traditional JS-nly instantiation — has good, but not great browser support. There’s a JS polyfill, but for such a core feature I’m not super jazzed about having to rely on it.

You can avoid the shadow DOM when using Lit, but if you want to benefit from the most compelling features (data binding, handlers in templates), you’ll need to at least use the render() method. This works great if Lit can be in control of your entire component’s render pipeline, but if you’re using server-side templating with an existing language, you’ll need to duplicate your template across two stacks (provided there are indeed similarities, rather than a totally dynamic client-side component).

For most components which enhance an existing bit of UI, (adding a button, hooking up a listener etc.), replacing and/or replicating the template feels impractical. You are, of course, free to manually query for and store references to existing elements via querySelector in (for instance) the connectedCallback lifecycle method, but at this point I would question what value Lit is providing.

Ruby sprinkles: Catalyst

GitHub’s Catalyst (v1, getting ready for a v2 launch with native decorator support) is a set of helpers for building native web components on-top of existing server-rendered templates.

A component looks like this:

Logo for TypeScriptTypeScript
@controller class HelloWorldElement extends HTMLElement { // Automatically adds getters for these elements, e.g.: // `get name() { return document.querySelector('[data-target="hello-world.name"]'); }` @target name: HTMLElement @target output: HTMLElement greet() { // Manually manipulte the DOM, rather than bind data to templates. this.output.textContent = `Hello, ${this.name.value}!` } }
Logo for HTMLHTML
<hello-world> <input data-target="hello-world.name" type="text"> <button data-action="click:hello-world#greet">Greet</button> <span data-target="hello-world.output"></span> </hello-world>

Unlike Lit, Catalyst does not take over rendering, although lit-html or @github/jtml can be used to handle DOM diffing and updates. DOM mutations need to happen manually, rather than via automatic binding. The docs suggest attaching rendering to the default web component attributeChangedCallback if you do need it, ensuring it runs every time an attribute is changed.

Catalyst uses in-template event handlers and automates sub-element selection (e.g. querySelector for children, via @target). There’s no built-in way to cache these selectors, meaning that you’re more likely to get runtime errors (missing a sub-element won’t throw an error until you try and use it) — however this does make it more resilient to DOM changes.

Catalyst also has auto event-binding, which occurs in-template via data-action. It’s a bit weird, since it means the JS logic is spread between the template and the component. This makes sense in Vue/JSX/Lit etc. due to collocation, but less so when your server templates live elsewhere. You’re free to manually attach event listeners yourself, although the automatic event binding does mean that new elements get their event handlers added automatically (thanks to a Mutation Observer).

There are other useful helpers, such as @attr, which removes the boilerplate around observedAttributes. It’s worth noting that the inbuilt attribute types are not as full-featured as Lit’s (which supports objects via JSON.parse() natively).

Considering Catalyst’s origins as a progressive enhancement layer for GitHub’s own Rails application, it’s a good fit for my needs.

The DOM sprinkles: Stimulus, htmx, Alpine.js etc.

In the no/little JS camp are libraries such as Stimulus, which aim to provide a bunch of plug-and-play common behaviour via data-attributes. These don’t appeal to me, in no small part due to the overhead of having to learn an additional library-specific syntax without the help of in-template type assistance.

In some ways these feel like the Tailwind of interactivity frameworks, providing a thin abstraction over a set of common utilities. Although most allow for extension, such as Alpine (which allows for custom directives), at this point I can’t help but feel like you’re learning a new very-specialised abstraction:

Logo for TypeScriptTypeScript
Alpine.directive('log', (el, { expression }, { evaluate }) => { console.log(evaluate(expression)); });
Logo for HTMLHTML
<div x-data="{ message: 'Hello World!' }"> <div x-log="message"></div> </div>

I think these libraries have some neat ideas, but they stray a little too far away from the platform for my taste. The inevitable lack of in-template linting and typechecking is also a big loss.

I don’t think writing less JavaScript is actually the answer.

Business as usual

Despite the loud minority, a lot of people either don’t want to (or can’t) adopt a fully-featured pipeline or framework. Much of the web still runs on traditional, server-side templating.

For my money, either writing native web components yourself or using something like Catalyst makes the most sense. You keep close to the platform, get to retain your progressive enhancement story and have an easy escape hatch if things go south.

In many ways nothing has changed. Used in this way, without the shadow DOM, web components simply provide a bit of sugar around registration and lifecycle (i.e. watching and responding to attribute changes). It’s not meaningfully different than simply passing a node to a constructor and listening for DOM updates.

Have any other ideas or a different experience? Let me know!


  1. Yew in Rust fulfils a similar role, essentially becoming your template engine and client-side (inter|react)ivity layer.↩︎

← Archive