A Gentle Introduction to SSR

I had the opportunity to deliver a session on SSR (Server-Side Rendering) at the CityJS Conference 2022 in late April — The Fastify SSR Workshop.

It was an attempt at breaking down SSR into its core concepts and building blocks through a series of iterations on the same application, and also an introduction to a new Fastify-flavored framework for integrating client-side applications and making them available for SSR — Fastify DX.

I think that session presents a really objective example of evolving a traditional application (that mainly generates HTML on the server side through a templating language) to an actual SSR application (we'll get to that in a bit!) — so I thought it would be a good idea to extract it into an article.

What is SSR?

Server-Side Rendering comprises various techniques involved in running JavaScript universally or as some like to say, isomorphically, that is, both on the server and on the client — in order to pregenerate markup for delivery and spare the browser from having to do so dynamically on page load — this is my definition, you'll find many others, but I think this captures the gist of it.

Running JavaScript universally is of course a big part of it — the only difference between a traditional web application and a SSR web application is that the latter is written in a way to reuse the same JavaScript code for page components to do the server-side rendering and the live, dynamic client-side rendering. One simple way to think about it is that you get static markup from the server that turns into a live single-page application once all the JavaScript code for the page is loaded.

See also Rendering on the Web by Addy Osmani and Jason Miller.

In the context of SSR, this process of loading the JavaScript code associated with markup that was delivered prerendered from the server is called hydration. This is an evolving area of experimentation, with newer frameworks starting to focus on the ability of delaying hydration, tying it to actual user interaction, or skipping it altogether when you don't need JavaScript at all.

The creator of the SolidJS framework, Ryan Carniato, gives a lot of background:

Also worth checking out Hydration is Pure Overhead, from Miško Hevery — who is the creator of Angular and is now working on a new framework called Qwik.

Why should we care about SSR?

One of the key factors that made SSR popular was that it allowed SPA applications to be prerendered on the server and deliver HTML to search engines instead of an empty page with a JavaScript bundle. Even though Google for instance will work around that — delivering prerendered markup always improved rankings.

But it's not just about search engines. SSR improves user experience: users don't have to wait for CPU-bound JavaScript to run on the client before they can see anything. This typically yields several improved metrics, such as First Paint, First Contentful Paint and Time to Interactive.

SSR also makes Static Site Generation (SSG) tooling easier, because SSG is essentially just that: prerendering your pages and saving them as static HTML files.

When should we avoid SSR?

SSR is ideal for dynamic, data-dependent applications. But there's an added cost of running live application servers. Avoid it if hosting API-backed SPA is more cost efficient. Avoid it if you can just pregenerate (statically render) everything. You can also consider having a hybrid setup, where you perform SSR for some pages and static delivery for others. Take all of this into account.

Evolving a Simple Application to SSR

Let's build a really simple application that displays a list. I'll walk you through evolving a very simple application that just outputs HTML from the server to a real SSR application where you get to reuse the same piece of code to do server-side rendering and client-side rendering and solve a few of the issues in doing so.

Get started by cloning this repository: galvez/the-fastify-ssr-workshop. Then run the snippet below from your terminal to get all dependencies installed.

cd examples
npm install
npx zx install.mjs

You'll need Node v16+ to run these.

As you progress reading this article, try and make stops to run and browse around the code to understand it better. All examples serve on http://localhost:3000.

The first iteration is in examples/todo-01.

cd todo-01
node server.js

It's made up of just two files: client.html —the HTML page template used to render a list, and server.js, the Node.js code to run a Fastify server to deliver it.

client.html reads as follows:

<ul>
<% for (const item of todoList) { %>
<li><%= item %></li>
<% } %>
</ul>

It uses Embedded JavaScript Templating (EJS) via lodash.template to generate the markup. This of course can still only run on the server.

Below is the server.js setup:

import Fastify from 'fastify'
import Template from 'lodash.template'
import { readFile } from 'fs/promises'

const app = Fastify()

const template = await readFile('./client.html', 'utf8')
const render = Template(template)

const todoList = ['Do laundry', 'Respond to emails', 'Write report']

app.get('/', (_, reply) => {
  reply.type('text/html')
  reply.send(render({ todoList }))
})

await app.listen(3000)

In case you're trying to figure out what's happening, after we create the Fastify server instance, we load the client.html file as a string and use lodash.template to precompile a rendering function. We then use that function on the / route handler to send some HTML to the client based on the todoList array.

Adding some SPA functionality

The second iteration is in examples/todo-02.

cd todo-02
node server.js

Still using the same two files, with one big difference: we can now add an item to that list through an input field, and we also take care of sending it to the server so the next time we load the page, it'll have the updated data.

Here's what client.html looks like now:

<ul>
<% for (const item of todoList) { %>
<li><%= item %></li>
<% } %>
</ul>
<input>
<button>Add</button>
<script>
document.querySelector('button').addEventListener('click', async () => {
  const item = document.querySelector('input').value
  const response = await fetch('/add', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ item })
  })
  if (await response.json() === 0) {
    const li = document.createElement('li')
    li.innerText = item
    document.querySelector('ul').appendChild(li)
    document.querySelector('input').value = ''
  }
})
</script>

Notice how we're doing a lot of vanilla JavaScript here — setting up a click listener, performing a fetch() request, manually adding an element to the DOM. If we were using a framework like Vue and some helper libraries, it would be a completely different story. But this is simple enough for educational purposes.

We also need to update the server to include an /add endpoint:

  app.get('/', (_, reply) => {
    reply.type('text/html')
    reply.send(render({ todoList }))
  })

+ app.post('/add', (req, reply) => {
+   todoList.push(req.body.item)
+   reply.send(0)
+ })

  await app.listen(3000)

So far, this example is looking a lot like the apps I built over a decade ago, often using the Flask Python micro web framework powering API endpoints to an SPA based on jQuery and templated with Jinja on the server.

Refactoring to Real SSR

Time to finally do some real server-side rendering.

We'll drop EJS in favor of DOM-flavored JavaScript using happy-dom — a library that polyfills the browser objects window and document on the server so we can run client code and still produce the same HTML.

You should not use this library for real applications, it's mostly targeted at testing. Emulating the DOM on the server has a lot overhead, but it should be just about perfect for educational purposes.

The final iteration is in examples/todo-03.

cd todo-03
node server.js

We begin by changing client.html to simply contain a <script> snippet — with type set to module so it's treated as an ES module:

<script type="module">
import { render, addEventListeners } from './client.js'
render(document, window)
addEventListeners()
</script>

In it we import client.js, a file we'll explore next, responsible for providing the rendering function (that prints the list) and another function to set up the UI event listeners (in this case, still only a click listener for the form button).

In client.js, here's how render() is defined.

export function render (document, { todoList }) {
  let html = '<ul>'
  for (const item of todoList) {
    html += `<li>${item}</li>`
  }
  html += '</ul><input>'
  html += '<button>Add</button>'
  document.body.innerHTML = html
}

In the client.html snippet that precedes the one above, notice how we call render(document, window) — here we are assuming todoList to be available in the window object, but it could another hydration data source.

We're now also effectively doing the rendering based on a data model, as opposed to manually adding new list items every time. If we have new data, we just run render() again and everything get's updated. This is of course not ideal, modern frameworks that care of doing DOM updates efficiently for you, but as is the case with happy-dom, this is simple enough for education purposes.

Moving on — we'll also need an extra endpoint to deliver the client.js file:

app.get('/client.js', (_, reply) => {
  reply.type('text/javascript')
  reply.send(createReadStream('./client.js'))
})

In a real Fastify application we'd just use a public/ folder and @fastify/static.

Now here's where we now get to do real SSR — we modify server.js to use the window and document shims provided happy-dom to allow us to run on the server the very same render() function from the client.js file that is delivered to the client.

+ import { Window } from 'happy-dom'
+ import { render } from './client.js'

  app.get('/', (_, reply) => {
+   const window = new Window()
+   const document = window.document
    reply.type('text/html')
+   render(document, { todoList })
-   reply.send(render({ todoList }))
+   reply.send(`${html}${document.body.innerHTML}`)
  })

So to recap — client.js is the one piece of universal JavaScript code in this application. It is written in such a way it can run both on the server (thanks to happy-dom), and natively on the browser as an ES module.

It's not quite done yet though. We need to handle two issues: one is hydration, making sure that the todoList data is available on the client so render() stays working if it's called again, e.g, when a new item is added via the UI, and the other is preventing render() from running on first render, because we already have the initial HTML we need from the server.

Here are the required changes to client.js:

+ let isClient = typeof window !== 'undefined'
+ let isFirstRender = true

  export function render (document, { todoList }) {
+   if (isClient && isFirstRender) {
+     isFirstRender = false
+     fetch('/data').then(r => r.json())
+       .then((json) => { window.todoList = json })
+     return
+   }
    let html = '<ul>'
    for (const item of todoList) {
      html += `<li>${item}</li>`
    }
    html += '</ul><input>'
    html += '<button>Add</button>'
    document.body.innerHTML = html
  }

Once render() runs on the client for the first time, isFirstRender is set to false and we load the data from todoList from a new endpoint, /data:

app.get('/data', (_, reply) => {
  reply.send(todoList)
})

The first time render() runs, we also return early and prevent the DOM from being updated — we already have the initial document rendered from the server. But now we're sure we have todoList ready to be updated on the client by the input form and the function can update the DOM the next time it's called.

All the input form needs to do now is call render() when a new item is added:

  document.querySelector('button').addEventListener('click', async () => {
    const item = document.querySelector('input').value
    window.todoList.push(item)
    const response = await fetch('/add', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ item })
    })
    const status = await response.json()
    if (status === 0) {
-     const li = document.createElement('li')
-     li.innerText = item
-     document.querySelector('ul').appendChild(li)
-     document.querySelector('input').value = ''
+     render(document, window)
    }
  })
}

Wrapping Up

If you're struggling to understand SSR at a fundamental level, I hope you found some enlightenment here! Ping me on Twitter if you need any clarifications.

The repository contains a few more examples covering how to use a very early alpha version of Fastify DX — a yet to be officially announced full stack framework for Fastify — to do SSR using Vue. That'll be the subject of a future blog post, after I'm done getting the first public beta release out!

Subscribe to the Fastify DX newsletter to hear about when it's launched.

Want to hear about new articles on this site? Follow me on Twitter Twitter