lamplightdev

How to server side render Web Components

Almost all modern framworks provide a way to server side render (SSR) a web site by running the framework code on a JavaScipt server side framework, such as express, to produce an intial string of HTML that can be sent to the browser. The same component code that runs in the browser is also run on the server.

With Web Components there are several limitations when it comes to SSR:

  • it’s not possible to run the same code on the server since Web Components rely on browser specific DOM APIs that are not available unless you fire up a headless browser or an alternative DOM implementation. Both of these solutions bring a non-trivial overhead that is hard to justify.
  • Shadow DOM cannot (currently) be represented declaratively so you cannot send it over in your initial string of HTML. Instead you’ll need to implement some other style encapsulation solution (much like the frameworks do.)

So Web Components are certainly at a disadvantage here compared to framework components, but they still offer two main advantages:

  • less code that runs faster as they use built in platform APIs
  • future proof as they can be used anywhere, including within current and future frameworks, without modification or risk of obsolesence.

So if these advantages are enough for you to choose Web Components over framework components, how can you SSR Web Components? Well since Web Components without the Shadow DOM are just standard HTML, it’s straightforward.

We’ll use a simple Twitter share button as an example. To see the final result have a look at the demo (or browse the code) and try with and without JavaScript enabled.

On the client

Our initial, browser based, web component may look something like this:

This is a simplified implementation that uses the initial attributes to render the component, with no reactivity to updated attributes or properties - but there is no reason why this could not be added at a later stage in the browser specific part of the code, using vanilla JavaScript or a 3rd party library (for example lit-html and/or lit-element). If you do go down this route also checkout lit-html-server which let’s you use the lit-html syntax on the server.

twitter-share.js

class TwitterShare extends HTMLElement {
  connectedCallback() {
    // set up the props based on the inital attribute values
    this.props = [...this.attributes].reduce((all, attr) => {
      return {
        ...all,
        [attr.nodeName]: attr.nodeValue
      };
    }, {});

    this.render();
  }

  render() {
    // set the innerHTML manually - ShadowDOM can't be used
    this.innerHTML = this.template();

    // add an event listener to the link inside the component
    const a = this.querySelector('a');
    a.addEventListener('click', this.open);
  }

  template() {
    // create the HTML needed for the component
    const { text, url, hashtags, via, related } = this.props;

    const query = [
      text && `text=${encodeURIComponent(text)}`,
      url && `url=${encodeURIComponent(url)}`,
      hashtags && `hashtags=${hashtags}`,
      via && `via=${encodeURIComponent(via)}`,
      related && `related=${encodeURIComponent(related)}`
    ]
      .filter(Boolean)
      .join('&');

    const href = `https://twitter.com/intent/tweet?${query}`;

    return `
      <a target="_blank" noreferrer href="${href}">
        Tweet this
      </a>
    `;
  }

  open(event) {
    // open the twitter share url in a popup window when the link is clicked
    event.preventDefault();

    const a = event.target;
    const w = 600;
    const h = 400;
    const x = (screen.width - w) / 2;
    const y = (screen.height - h) / 2;
    const features = `width=${w},height=${h},left=${x},top=${y}`;

    window.open(a.getAttribute('href'), '_blank', features);
  }
}

customElements.define('twitter-share', TwitterShare);

However, to render this on the server you can’t use HTMLElement, customElements or addEventListener for example as those APIs don’t exist on the server, so you’ll need to extract the template method into a standalone function that can be used:

twitter-share-template.mjs

// .mjs is used here so javascript modules can be used on the server
export default props => {
  // the same logic as above, in a self contained function
  const { text, url, hashtags, via, related } = props;

  const query = [
    text && `text=${encodeURIComponent(text)}`,
    url && `url=${encodeURIComponent(url)}`,
    hashtags && `hashtags=${hashtags}`,
    via && `via=${encodeURIComponent(via)}`,
    related && `related=${encodeURIComponent(related)}`
  ]
    .filter(Boolean)
    .join('&');

  const href = `https://twitter.com/intent/tweet?${query}`;

  return `
    <a target="_blank" noreferrer href="${href}">
      Tweet this
    </a>
  `;
};

and update our browser based component definition to import and use that function:

twitter-share.js

import template from './twitter-share-template.mjs';

class TwitterShare extends HTMLElement {
  //...as before

  render() {
    this.innerHTML = template(this.props);

    // ...as before
  }

  // template() -> removed

  // ...as before
}

customElements.define('twitter-share', TwitterShare);

On the server

On the server, while constructing the page response you need to use the HTML tag for our component with the necessary attributes that will be parsed by the browser, and for the SSR content of the component use the same template function as above:

server.mjs

import template from './public/js/twitter-share-template.mjs';

// ... the server side route

const props = {
  text: 'A Twitter share button with progressive enhancement',
  url: 'https://grave-mirror.glitch.me',
  via: 'lamplightdev'
};

// be sure to sanatize the props if including directly in HTML

response.write(`
  <twitter-share text="${props.text}" url="${props.url}" via="${props.via}">
    ${template(props)}
  </twitter-share>
`);

// ...

To see the component in action have a look at the demo and browse the code. Be sure to try the page with and without JavaScript enabled.

In summary, you can SSR web components without any server side DOM implementation, but only without the Shadow DOM.