Skip to content

Enhance vs. Lit vs. WebC…or, How to Server-Render a Web Component

The era of custom elements SSR is upon us. Let's take a look at how to spin up a simple Node server and use custom elements as templates in three popular formats, and what this means for the future of web components.

By Jared White

One thing I’d like to get off my chest right away.

It was always possible to render custom elements on the server. Any server.

Setting any “web component-y JavaScript-y” bits aside, you could always output something like this directly in your HTML:

<my-custom-element>
  <my-custom-header><h1>Wee!</he></my-custom-header>
  <my-custom-button variant="primary"><button>A button!</button></my-custom-button>
</my-custom-element>

And then you could style all of those custom elements however you like:

my-custom-element {
  display: block;
  background: lightyellow;
  padding: 1rem;
}

my-custom-header {
  display: block;
  padding-bottom: 1rem;
  margin-bottom: 1rem;
  border-bottom: 1px solid rebeccapurple;
}

my-custom-header h1 {
  margin: 0;
}

my-custom-button button {
  background: gray;
  color: white;
  padding: 0.75rem 1rem;
  font: inherit;
  border: none;
  border-radius: 3px;
}

my-custom-button[variant="primary"] button {
  background: navy;
}

Look Ma! Custom elements! Rendered by a server! JavaScript totally optional! 😲

OK OK, I know what you’re thinking. I’m leaving out some key details…

Component-Centric View Architecture and Shadow DOM #

What people usually mean when they ask if they can SSR their web components is this: hey, can I take the code I’ve already written for client-side web components using all of the web component APIs (especially shadow DOM), and basically take an on-the-fly snapshot of that render tree and stick it in my output HTML (whether for a static site or a dynamic server)?

And so we’ve seen various projects, most notably Lit, tackle this very problem and provide a mechanism to render web components on the server, with an optional hydration step on the client to take those server-rendered components and make them interactive after initial load.

Yet, this sort of “isomorphic” architecture where client and server code is essentially identical has increasingly fallen out of step with what many developers actually want. Components which only run on the server are making a big splash across a variety of ecosystems, with even React/Next.js getting in on the action.

I like using the term “split components” which I first heard about from the Marko project. A split component essentially divides each component into two: a server-only portion, and a client-only portion. Sometimes you can pull some or all of the server logic over to the client as well for full isomorphism, but oftentimes you simply don’t need to.

I’m going to make a bold statement to kick off our explorations of web component SSR’ing today:

The future of web components is split components.

Or to put it another way, the future of web components is HTML-first, server-first—a mantra I’ve been preaching for a long time.

But this means we have a lot to do. The web components spec historically has been pretty squarely focused on client-side APIs which potentially make the most sense in a world of client-driven app architecture, namely SPAs. It pains me that as many frameworks are deep in the throes of migrating away from SPA-as-default across our industry—thank god!—web components feel behind the times. But there are promising signs ahead.

Declarative Shadow DOM lets us server-render the internals—the guts if you will—of web components and ship those directly to the browser. Yes, Firefox still needs a simple polyfill which is really no biggie, and for Chrome and Safari you can display your server-rendered components including shadow DOM with zero JavaScript. Pretty darn cool.

Furthermore, there are ongoing efforts to spec other parts of the web component APIs for a world of declarative markup. In the future we might gain declarative custom element registration and declarative ElementInternals functionality and even declarative reactive templates using DOM parts. People who work on this stuff at the spec and browser implementation level now understand the gravity of the situation. If a major API is only available to client-side JavaScript, it’s less likely to work well in an HTML-first, server-first landscape.

But…we’re not here to dream about the future. We’re here to see what we can do right here, right now. And so on with the show!

There’s Node Business Like Sh(ad)ow Business #

Groan. 😅

I’m going to demonstrate three different component formats which you can use today in any Node project inside of any potential template rendering environment. These are:

Of the two, Enhance and WebC are closer in terms of general concept. Both start with a “0kb JS” mindset and use terminology of “expansion”—aka, your top-level HTML templates include custom element tags, which then are “expanded” with the full rendered components…and those components might expand other components, and so on and so forth.

Lit’s certainly a bit different and, as a popular library for web component authors, is a well-known quantity. Like I said, Lit is typically used for “isomorphic” components (aka the code you SSR and the code you load client-side is virtually identical). However, there’s actually no hard-and-fast reason you have to do it this way. And so I demonstrate that Lit too could be used to author “split components”—although you’re fairly “on your own” in term of practical applications of it.

Starting in alphabetical order, let’s first look at Enhance.

Region 3. Move Down 5. Zoom In 300%. Enhance! #

Demo Repo

Enhance made a splash a while back as a fullstack framework you can use to build entire web applications, all centered around the concept of server-rendering custom elements. What’s particularly nice about how Enhance is architected is that the piece which deals directly with component SSR is a standalone library anyone can grab off the shelf and use.

Here’s an example of an Enhance component:

export default function MyElement({ html, state }) {
  const { attrs } = state
  const { hello='?', easy=[] } = attrs
  return html`
    <style>
      :host {
        display: block;
        padding: .5rem 1rem;
        border: 3px double orange;
      }

      h2 {
        color: maroon;
      }
    </style>

    <h2>Hello ${hello}!</h2>

    <p>Easy as ${easy.join(", ")}. <slot></slot></p>
  `
}

And here’s a (simplified) example of rendering it in a main server process:

import MyElement from "./my-element.js"
import enhance from "@enhance/ssr"

const html = enhance({
  elements: {
    'my-element': MyElement,
  }
})

const data = {
  hello: "world",
  easy: [1, 2, 3]
}

const result = html`
  <my-element hello=${data.hello} easy=${data.easy}>Boom done.</my-element>
`

You can look at the full example code in the repo link above.

What I like about this is its conceptual simplicity. There’s not a lot you need to learn. If you know the basics of HTML, CSS, JavaScript, and Node, you can use this to DIY your own web framework.

Enhance also provides some nice style scoping features right out of the box. By default, Enhance components do not use shadow DOM. You can certainly opt-into it though, but if you don’t, you can still use some of the same nomenclature in your component styles like :host, and all of the selectors will end up prefixed with the tag name automatically. In other words:

and so on. Neat-o!

Because Enhance components are server-only components from the start, the way you add your client-side web component code is simply by adding a script tag to your component template. So for example, I have an s-dom.js Enhance component which uses a combination of Declarative Shadow DOM and “light DOM” content, which is fantastic, and my client component is simply embedded at the bottom of the template like so:

<script type="module">
  class SDomElement extends HTMLElement {
    static {
      customElements.define("s-dom", this)
    }

    connectedCallback() {
      const para = this.shadowRoot.querySelector("p")
      para.style.border = "1px dashed gray"
      para.style.padding = "0.5em"
    }
  }
</script>

Enhance is smart enough to collect all of the scripts in the entire render tree and include them only once at the bottom of <body>. (And components styles are also collected and included in <head>.)

While there’s much to recommend here, there are also some things I don’t like about Enhance which I’ll cover shortly. I also had to patch Enhance so that I could get at its rendered <head> content separately from the <body> content. I think Enhance is used to the idea it’s always rendering an entire HTML page template. I filed an issue on GitHub, so we’ll see if this is a use case they’d like to support going forward.

This Component is Lit. Lit AF. #

Yes, it’s mandatory to engage in juvenile humor when talking about Lit. Listen, I don’t make the rules!

Demo Repo

So for our Lit example, things are a wee bit different than Enhance. Lit always defaults to using shadow DOM, and while you can switch it off, stuff sort of breaks because slotted content is no longer a thing (and scoped styles are more of a pain). So I just left it all as is. I’m a huge shadow DOM fan so that doesn’t bother me, but for folks who have an aversion to it or it doesn’t play well with the rest of their HTML/CSS architecture, it might prove a hurdle.

Here’s a component in Lit, similar to the first Enhance example:

import { LitElement, html, css } from "lit"

export class MyElement extends LitElement {
  static styles = css`
    :host {
      display: block;
      padding: .5rem 1rem;
      border: 3px double orange;
    }

    h2 {
      color: maroon;
    }
  `

  static properties = {
    hello: {},
    easy: { type: Array },
  }

  constructor() {
    super()
    this.hello = '?'
    this.easy = []
  }

  render() {
    return html`
      <h2>Hello ${this.hello}!</h2>

      <p>Easy as ${this.easy.join(", ")}. <slot></slot></p>
    `
  }
}

customElements.define('my-element', MyElement)

Pretty standard fare if you’re used to the Lit API. The question is, how easily can you server-render this component? Well, you be the judge:

import { render } from "@lit-labs/ssr"
import { collectResult } from "@lit-labs/ssr/lib/render-result.js"
import { html } from "lit"
import { MyElement } from "./my-element.js"

const data = {
  hello: "world",
  easy: [1, 2, 3]
}

const result = await collectResult(render(html`
  <my-element hello=${data.hello} .easy=${data.easy}>Boom done.</my-element>
`))

In a streaming scenario, they recommend working with RenderResultReadable so the output response can flush at various intervals to the browser, but in simpler contexts this seems to work just fine.

The split component mechanism is a bit wonky, but still doable. I ended up adding a separate template as a static property of the component, and then including that in a separate render of JS assets:

// in s-dom.js
static clientComponent = html`
  <script type="module">
    class SDomElement extends HTMLElement {
      static {
        customElements.define("s-dom", this)
      }

      connectedCallback() {
        const para = this.shadowRoot.querySelector("p")
        para.style.border = "1px dashed gray"
        para.style.padding = "0.5em"
      }
    }
  </script>
`

// and then later:
const jsResult = await collectResult(render(SDom.clientComponent))

So…this is doable, but it probably just makes more sense to have a separate .js file for your client component and include that as part of whatever frontend build/bundling pipeline you might have. Maybe you even adopt .server.js and .client.js conventions, and for components where you want the same one running in both places….iso.js? .hybrid.js? Hmm.

(It should be noted the Enhance example doesn’t offer an isomorphic possibility, only split components. So if you think you’ll actually need to write lots of isomorphic components, Lit becomes awfully attractive…although the number of caveats you need to keep all in your head is quite daunting.)

WebC Lets You Render Components for the Web, See? #

Yeah, see? You can now render components on the server, see? Yeah!

Like Johnny Rocco, I “want more” when it comes to options for custom elements SSR, and so we arrive at our third and final demonstration which is WebC.

Demo Repo

WebC grew out of the Eleventy project, and like Enhance you can simply spin up an Eleventy site and install the WebC plugin and you’re off to the races with a full framework (albeit mostly oriented towards static sites).

For our purposes, WebC can be used standalone without any major gotchas and the API is straightforward (although once again I must admit it took me a bit of time to stumble onto the right formula).

It’s worth explaining up front that WebC is quite different from Enhance and Lit in terms of the syntax of writing a component. WebC doesn’t use JavaScript files like the other two. WebC provides its own HTML-based SFC (Single-File Component) format which looks a lot more like Vue or Svelte. JavaScript can be embedded into component files, whether it’s code to run on the server or code to include as scripts for the client.

Here’s an example of a WebC component:

<script webc:setup>
  const join = (input) => input.join(", ")
</script>

<style webc:scoped="my-element">
  :host {
    display: block;
    padding: .5rem 1rem;
    border: 3px double orange;
  }

  h2 {
    color: maroon;
  }
</style>

<h2>Hello <span @text="hello"></span>!</h2>

<p>Easy as <span @text="join(easy)">...</span>. <slot></slot></p>

And here’s how to render it. We’ll use a page entrypoint to include the element and then render that entrypoint from our Node process:

<my-element :@hello="hello" :@easy="easy">Boom done.</my-element>
import { WebC } from "@11ty/webc"

const page = new WebC()

page.defineComponents("./webc-ssr/components/*.webc")
page.setInputPath("./webc-ssr/page.webc")
page.setBundlerMode(true)

const data = {
  hello: "world",
  easy: [1, 2, 3]
}

const { html, css, js } = await page.compile({ data })

WebC happily provides you with separate HTML, collected styles, and collected scripts which you can then include inline on your page or hand off to some other frontend process.

WebC also provides a “light DOM” scoping mechanism similar to Enhance, except that it uses hashed class names by default and you have to manually specify a human-readable class name (and then it’s a class not the actual tag name as the selector prefix). It’s fine, but I’d prefer true scoping at the element level. Certainly shadow DOM can also be used in WebC for style scoping.

Unfortunately, the interactions between WebC server-level slots and shadow DOM slots are painful, far more than in Enhance. Within your Declarative Shadow DOM template, you have to add a webc:keep attribute to maintain proper semantics in the output HTML. And for “light DOM” tags which need to preserve a slot attribute and avoid processing by WebC, you have to add something like @attributes="({ slot: 'hello'})". Again, it really took some time to figure this all out! There’s an issue to track this if you’re curious to see if the WebC folks can come up with a simpler solution.

It also took me a beat to figure out how to process incoming component state in JavaScript (aka to join the incoming array into a string) in my component template. Providing a function in <script webc:setup> to use in the HTML portion is pretty obvious in hindsight, but I fiddled around with a lot of other approaches before stumbling across this one.

In spite of these gotchas, I really appreciate WebC’s aesthetic, from its API design to the HTML template syntax. Others may disagree, but I truly enjoy authoring HTML as HTML, and I’ll talk much more about this in a moment. (Just be aware that, like Enhance and unlike Lit, WebC doesn’t offer isomorphic components, only split components.)

Enhance vs. Lit vs. WebC: Who Wins? #

The short answer: none of them. 🤡

So here’s a thing which really annoys me about the Node ecosystem. There’s no community protocol or meta-library (that I’m aware of at least) which lets you easily render templates from any server configuration.

In Ruby, we have Tilt. Tilt lets you render templates in a wide variety of languages, both out of the box or via third-party plugins. And by way of example, I actually created my own template language a while back as an offshoot of ERB, called Serbea. Because Serbea is available through Tilt, you can render Serbea templates from any Ruby web framework easily. Adding support for Rails was mere lines of code!

Now this does break down a bit when talking about real server components. Ruby frameworks have to supply their own partial/component render methods to use with Tilt. We also have work to do here it would seem.

Nevertheless, in an ideal world, there’s be “Nilt” (Node…Interface (for) Loading Templates? Hey, that tracks!), and Enhance and Lit and WebC and a dozen other flavors of this stuff would all just plug into “Nilt” and a variety of web frameworks would know how to use “Nilt” for views and you’d be up and running in no time. Choose your flavor.

Instead…if you’re going to use Enhance you might as well use Enhance (the framework). And if you’re going to use WebC you might as well use Eleventy. And if you’re going to use Lit…well, good luck with that. There’s no “Next.js but for Lit” (yet). Maybe use Astro?

(Speaking of Astro, it also comes with a shiny-new server component format which you could use alongside custom elements. But it only works in Astro! 😭)

I think it’s vitally important we keep talking about and emphasizing the need for interop, hence the reason I wanted to write this article. And here’s where I really get on my soapbox, so buckle up folks!

The promise of web components in browsers is that you get a true write once, run anywhere API. I can write my card or my color picker or my audio player or my site header or my business dashboard once, and utilize those components anywhere. I don’t need to tear down the whole world and rebuild from scratch because I’m using Vue, no wait React, no wait Svelte, no wait Solid, no wait Qwik, no wait…

And because web component APIs use declarative HTML standards as their launching pad, in theory my web server could be anything—as it should be. Render web components from Ruby! From Python! From Java! From Elixir! From whatever the heck you and only you like!

What we truly need is a sense of server-side interop, a vision of a component format where you could squint and see how server components could be authored in one environment and ported over to another environment without too much hassle.

And that brings me to my actual clear winner in all of this. Drumroll, please! 🥁

It’s WebC.

WebC: an Enchanting Path Forward #

WebC is the only tool I’m aware of on the market today which lets you author web components as HTML from the very start. The .webc extension of a component highlights that while this isn’t pure HTML, it certainly isn’t JavaScript either. And that’s the Achilles’ heel of technologies like Enhance or Lit. It’s HTML-in-JS, CSS-in-JS…nothing but .js files as far as the eye can see.

I really hate writing HTML in JavaScript files. 😬

It’s not how the web was architected. The browser downloads HTML first—then processes any styles or scripts which are either contained inline or linked to externally. As someone with nearly 30 years of experience working on the web, I instinctively think HTML-first when I think about how to structure my page, structure my layout, structure my components. The content always comes first, then presentation, then behavior. HTML, then CSS, then JavaScript.

This is the way GIF from The Madalorian TV show

And past eras of tooling understood this. Most “old-school” HTML template languages start with pure HTML-as-string-data and let you sprinkle in your code bits. Heck, PHP is essentially an entire language that was built to be HTML-first! The reason you have to include <?php at the top of every PHP code file is because otherwise it’s just a text template! (Look at this opening example in PHP’s documentation if you don’t believe me…)

The modern evolution of this concept is a component format which actually parses your HTML server-side to turn it into the server equivalent of a DOM tree, and then walks the tree searching for special attributes, directives, handlebars, etc. to apply processing. This is slower and more effort than basic string manipulation/RegEx-fu like the older template formats, but it provides opportunities for better syntax, fewer bugs and security holes, and tighter integration between the HTML markup and the underlying server programming language.

WebC takes full advantage of this concept. When you write <span @text="msg"></span> instead of something like <span><?php echo $msg; ?></span>, you’re moving from old-and-busted “inject string output here in text file” to new-hotness “when you process this dynamic attribute, swap the execution output into this node’s contents”. In other words, you’re writing a declarative template, but it’s translating that into imperative manipulations of the DOM, just like it would on the client in a component format there.

And while WebC only runs in a JavaScript server context, you could imagine applying these same concepts and using the same component format nearly unchanged elsewhere. Yeah, that’s right. Imagine, say, a “WebR” that’s a Ruby port of WebC. The above WebC example could look like this:

<script webc:setup type="application/ruby">
  def join(input) = input.join(", ")
</script>

<p>Easy as <span @text="join(easy)">...</span>. <slot></slot></p>

Here’s what’s astounding about this hypothetical example. I rewrote the script block which executes on the server as Ruby code, but the HTML template didn’t have to change. At all.

Very little would have to change when you think about it. In server-rendered applications, most logic lives elsewhere. Controllers or routes pull content from databases and handle requests, models or entities encapsulate records, and you can easily write functions or PO(X)Os (Plain Old Ruby / JavaScript / Python / etc. Objects) to mange all sorts of business logic. The view layer only has to provide a base level of smarts to take a data structure defined elsewhere and translate it into markup.

It’s only in the so-called “modern” world of SPAs where components have fast expanded like a virus to take over the bulk of application architecture. You’re fetching data from APIs and handling forms and validating data and executing business logic all from view-layer components. It’s nothing but another form of big ball of mud software architecture.

We have a real opportunity to reset expectations and reassert best practices and codebase health, and I’m very excited about this. I can easily see a future where:

This way, you don’t really need to learn the “Enhance” way or the “Lit” way or the “Astro” way or the this way or the that way to just write a freakin’ web component. 🤪

Put another way, we desperately need a common language around thinking of web components as fullstack components. Custom elements aren’t merely a way to spin up some JavaScript and attach it to a node in the browser. They form the baseplate of the view layer of your web application.

I have to say, I really do appreciate what the Enhance folks are doing. Because they too see a world of fullstack custom elements-as-view-layer and are making that a reality today in the Enhance framework. It’s good stuff.

But then again, I keep getting tripped up by the JavaScript-centric nature of it. I can never truly get onboard with a universal web component format which is unable ever to be rendered by the infinite number of other web servers out there which aren’t Node/Deno/Edge/etc..

And so I find myself extremely enthused by WebC. I think Zach Leatherman struck gold when working on this format. And even if we end up with further permutations down the road (and admittedly it’s an area of intense experimentation for me), WebC is the technology to take seriously today.

Want to join a fabulous community of web developers learning how to use “vanilla” web specs like HTTP, HTML, CSS, JavaScript, & Web Components—plus no-nonsense libraries & tools which promote developer happiness and avoid vendor lock-in?

Join The Spicy Web Discord

It’s entirely free to get started. And we’ll soon be launching paid courses to take you even deeper down the rabbit hole, so stay tuned! Vanilla has never tasted so hot.