I recently launched a site built entirely with Web Components. Several views where static, meaning I could just server side render them wholesale without must hassle. When it came to the blog I felt stuck. The blog is dependent on the asynchronous response from a REST API. How was I going to server side render this view?
The solution turned out to be easy!
The BlogComponent
shown in the code snippet below is a custom element that represents the view. During the connectedCallback
the method getModel
is called.
class BlogComponent extends HTMLElement {
...
connectedCallback() {
this.getModel();
}
getModel() {
return new Promise((res, rej) => {
fetch('http://localhost:4444/api/blog')
.then(data => data.json())
.then((json) => {
this.renderPosts(json);
res();
})
.catch((error) => rej(error));
})
}
renderPosts(data) {
...code for rendering DOM elements
...lots of createElement, appendChild, yada yada
}
The getModel
method returns a Promise so when the component is server side rendered the Express middleware can wait for getModel
to finish before responding with the rendered view. The important thing to consider here is when server side rendering Express will need to wait for the HTTP request to finish before responding to a client with the rendered HTML for the page.
In the Promise, I used fetch
to make the HTTP request to /api/blog
. I call res()
after the component renders the view using the data from the JSON response. In this example, renderPosts
is a blocking function. You may use any pattern you see fit. Perhaps you want to implement MVC or service pattern for your components. Go ahead! There just needs to be a way at the component level server side rendering can analyze the class and determine "Does this component require data before I render it?"
During a build step this component is bundled with every other component and route configuration for server side rendering. I map a reference to each component to the path the user will visit, along with a title that can be viewed in the browser window.
export const routes = [
{ path: '/', component: HomeComponent, title: 'Home' },
{ path: '/blog', component: BlogComponent, title: 'Blog' },
{ path: '/404', component: FileNotFoundComponent, title: 'File Not Found' }
]
The bundle is imported into Express middleware that handles the server side rendering. For server side rendering custom elements I used the @skatejs/ssr
package. The middleware is below.
require('@skatejs/ssr/register');
const render = require('@skatejs/ssr');
const url = require('url');
const path = require('path');
const fs = require('fs');
const { routes } = require('path/to/bundle.js');
const indexPath = path.resolve('path/to/index.html');
const dom = fs.readFileSync(indexPath).toString();
function generateHTML(template, route, dom){
return dom
.replace(`<title></title>`, `<title>${route.title}</title>`)
.replace(`<div id="root"></div>`, `<div id="root">${template}</div>`)
.replace(/__ssr\(\)/g, '')
}
export default async(req, res, next) => {
let component = {};
const route = routes.find(rt => rt.path === url.parse(req.url).pathname);
if (route == undefined) {
res.redirect(301, '/404');
return;
} else {
component = route.component;
}
if (component) {
const preRender = new component();
if (preRender.getModel) {
try {
await preRender.getModel();
} catch(e) {
next(e);
}
}
const template = await render(preRender);
res.send(generateIndex(template, route, dom));
} else {
res.send(dom);
}
}
async/await made this code somewhat compact. After the middleware establishes a component is mapped to this route by parsing the request url and checking against a route in the imported config, the component is instantiated. If a route doesn't match, the browser will be redirected to a 404
route.
const route = routes.find(rt => rt.path === url.parse(req.url).pathname);
if (route == undefined) {
res.redirect(301, '/404');
return;
} else {
component = route.component;
}
if (component) {
const preRender = new component();
If a class passes through this middleware has the getModel
method, getModel
is called using the await
keyword. The getModel
method returns a Promise that ensures the component has rendered the template after successfully making the HTTP request. The code is wrapped in a try / catch in case something fails (either the HTTP request or the render method in the component).
if (preRender.getModel) {
try {
await preRender.getModel();
}
catch(e) {
next(e);
}
}
But wait, you are calling fetch
from node, but inside code that is normally client side?
Remember how I said the components are bundled specifically for server side rendering in a build step? In the entry point for that bundle, I imported node-fetch
and put it on the global namespace.
import fetch from 'node-fetch';
global['fetch'] = fetch;
I put objects on the global namespace here normally when I want to mock browser based APIs for server side rendering. node-fetch
is an implementation of the fetch API that allows the component to make the HTTP request in node.
The next await
that follows is @skatejs/ssr
method for rendering Web Components server side. @skatejs/ssr
is the magic sauce. The render
method takes the component in the first argument and returns the HTML. This HTML will be in the response from the server, but first I have to inject the component HTML into the DOM.
const template = await render(preRender);
res.send(generateIndex(template, route, dom));
The generateIndex
method takes the HTML that was retriever earlier in the code from a static file and places the server side rendered HTML in the #root
element. I had to massage the output of the render
method a bit and remove any calls to a __ssr
function that it was injecting for @skatejs
formatted components. The generateHTML
method also sets the content of the title
tag for SEO purposes.
function generateHTML(template, route, dom){
return dom
.replace(`<title></title>`, `<title>${route.title}</title>`)
.replace(`<div id="root"></div>`, `<div id="root">${template}</div>`)
.replace(/__ssr\(\)/g, '')
}
The results are impressive. Express responds with the server side rendered blog posts on the initial request from the browser.
Easy Rant
A few months ago I read somewhere it can't be done. "You can't server side render web components" they said, justifying reasoning for sticking with React rather than adopting custom elements v1. One of my latest projects demonstrates you can not only SSR static pages with custom elements, but also components that are dependent on asynchronous data from a REST API.
You can do anything with custom elements you can do with component based JavaScript libraries and frameworks, with less code and possibly more performant than Virtual DOM. It really comes down to implementation. In this example I demoed a simple implementation for server side rendering a web component using a class method. You're free to use any pattern. Free as a bird. Don't you want to be a bird?
Top comments (4)
Nice! This is the beginning of something awesome. It would be great if you can standardize methods/API that custom elements should have so that then people can install the custom element SSR as a lib. A next step would be that it can support async network requests at any level in the DOM tree, not just the root route component. There would need to be a way to signal that the tree has finished loading, or something.
But yeah! SSR isn't limited just to libs like React, and you're proving it! Someone just needs to implement it, and now you have! Nice work!
Also check out this interesting article! medium.com/@mlrawlings/maybe-you-d...
It's be sweet to achieve what Marko has SSR-wise, but with Custom Elements. The syntax is nice sugar too.
This gave me a lot of hints on trying out ssr'ed web components through my framework PlumeJS
Nice article.