blog.larah.me

React Server Side Rendering with Hypernova

April 10, 2017

In this tutorial, we’ll learn how to set up Server Side Rendering of React Components with Hypernova.

Situation: You’ve written a React app, and your website works great. Of course it does! But you begin to wonder, how can I improve performance on the client? The answer, of course, is Server Side Rendering!

Actual footage of a user loading an SSR-enabled website for the first time.

Actual footage of a user loading an SSR-enabled website for the first time.

Here are some great benefits to Server Side Rendering (SSR) your React components:

  • Improved SEO Results 1
  • No waiting for JavaScript to load on the client
  • Works (sorta) for users with JavaScript disabled

There are a few projects that we could use to do SSR:

Why?

Why not just use ReactDOMServer.renderToString? In a production environemnt, we also want to have things like caching, isolated rendering and multithreading in order to be able to scale.

The projects listed above allow us to solve these problems. They’re all decent options, but we’ll go with Hypernova here.

If you want to follow along, git clone this repo for the example code.

Setup

(Scroll down if you want to skip the assumed setup.)

Let’s say you already have the existing following bare-bones code structure:

$ tree
.
├── .babelrc
├── .gitignore
├── assets
│   ├── index.html
│   └── jsx
│       ├── index.jsx
│       └── sheep.jsx
├── package.json
├── webpack.config.js
├── webserver.js
└── yarn.lock

2 directories, 9 files

Where index.jsx and sheep.jsx look like the following:

// index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import Sheep from './sheep';

ReactDOM.render(<Sheep />, document.getElementById('root'));
// sheep.jsx

import React from 'react';

export default () => (
    <div>
        <p>beep beep I'm a sheep</p>
    </div>
);

Sheep is a “top-level” component, a component that gets loaded into the page with ReactDOM.render. Any components that are nested within Sheep, we don’t care about - it’s only these top level components that we’ll be passing to the SSR server.

(Sheep way prefer baaaackbone over React.)

To complete the picture, we transpile with babel and use webpack to create a bundle that gets loaded into a template index.html:

<!DOCTYPE html>
<html>
    <head>
        <title>My Example App</title>
        <meta charset="utf-8" />
    </head>
    <body>
        <div id="root"></div>
    </body>
</html>

Running yarn start runs webpack, spits out a build folder, launches a webserver and everything works as expected.

beep beep

This gives us a (very!) bare bones React app. We can now begin to add the SSR :)

You can checkout the branch stage-1 in the example repo to get to this point.

Setting up Server Side Rendering

Our website is in great shape, and now it’s time to add SSR!

Instead of sending over a static compiled template that contains everything, we’ll just keep the outer HTML structure in a template and let SSR components fill in the middle bits.

We’ll do this in a few stages:

  • Building a bundle for all our entrypoints
  • Building a server to consume the bundle and serve the components
  • Splitting up the index.html template to take in SSR components
  • Refactoring our web server to serve the combined template + components

Eventually, our stack will look like this:

Building the entrypoints bundle

We will again use webpack to generate the bundle for the server side renderer. Here’s what this process looks like:

We will define a file, components-entrypoint.jsx, to import all the top-level components that we need. (Remember, a top-level component is defined by anything we’d otherwise call ReactDOM.render on. In our case, Sheep. For a new application, you’d probably only want one entrypoint.)

Here’s what our components-entrypoint.jsx file looks like:

import Cow from './jsx/cow';
import Pig from './jsx/pig';
import Sheep from './jsx/sheep';

export { Cow, Pig, Sheep };

We now need to modify our webpack setup to generate an additional bundle for SSR, using our components-entrypoint.jsx file.

(You could place this in the existing webpack.config.js file and export an array of webpack configs, but we’re going to make a new webpack config file to keep these two concerns seperate.)

Here is webpack.ssr.config.js:

const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
    entry: './assets/components-entrypoint.jsx',
    target: 'node',
    externals: [nodeExternals()],
    output: {
        libraryTarget: 'commonjs',
        path: path.join(__dirname, 'ssr'),
        filename: 'ssr-bundle.js',
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                use: 'babel-loader',
            },
        ],
    },
    resolve: {
        extensions: ['.js', '.jsx'],
    },
};

Running yarn run build-ssr spits out the compiled bundle.

Checkout the branch stage-2 in the example repo to get to this point.

Serving the components

Now that we’ve set up the bundling, let’s create the Hypernova service to serve these components. Hypernova recommends doing this as a separate server:

The recommended approach is running two separate servers, one that contains your server code and another that contains the Hypernova service. You‘ll need to deploy the JavaScript code to the server that contains the Hypernova service as well. 2

This is especially useful advice when deploying as part of a microservice architecture (having multiple concerns running in one containers is hairy 3, and scaling becomes easier for a dedicated container). We’ll “deploy” to a subdirectory in this tutorial, but we would ideally containerize each server individually, with only the compiled bundle needing to be shared.

In the directory ssr, let’s create the following Hypernova server:

//  hypernova-server.js

const bundle = require('./ssr-bundle');
const hypernova = require('hypernova/server');
const renderReact = require('hypernova-react').renderReact;

hypernova({
    getComponent(name) {
        for (let componentName in bundle) {
            if (name === componentName) {
                return renderReact(componentName, bundle[componentName]);
            }
        }

        return null;
    },
});

We can verify the server works by running the server with yarn run start-ssr and querying the API for our Sheep component:

$ curl -X POST localhost:8080/batch \
  -H Content-Type:application/json \
  -d '{"mysheep": {"name":"Sheep", "data": {}}}' | jq
{
  "success": true,
  "error": null,
  "results": {
    "mysheep": {
      "name": "Sheep",
      "html": "<div data-hypernova-key=\"Sheep\" data-hypernova-id=\"684d437b-dda0-4695-9e68-b544c5b98b97\"><div data-reactroot=\"\" data-reactid=\"1\" data-react-checksum=\"255991580\"><p data-reactid=\"2\">beep beep I&#x27;m a sheep</p></div></div>\n<script type=\"application/json\" data-hypernova-key=\"Sheep\" data-hypernova-id=\"684d437b-dda0-4695-9e68-b544c5b98b97\"><!--{}--></script>",
      "meta": {},
      "duration": 1.262021,
      "statusCode": 200,
      "success": true,
      "error": null
    }
  }
}

Checkout the branch stage-3 in the example repo to get to this point.

Updating index.html

Let’s turn index.html into a function that accepts some markup:

// index.template.js

module.exports = sheepMarkup => `
    <!DOCTYPE html>
    <html>
    <head>
        <title>My Example App</title>
        <meta charset="utf-8">
    </head>
    <body>
        ${sheepMarkup}
        <script type="text/javascript" src="bundle.js"></script>
    </body>
    </html>
`;

This enables us to get the markup from Hypernova and pass it down as an argument. (In the real world where markup probably already lives in templates, this may not be easily doable. This will be discussed further in a future blog post, but the answer for now is string substitution.)

Checkout the branch stage-4 in the example repo to get to this point.

Combining it all

Finally! Now we can call Hypernova from our webserver and plug the rendered React markup into our template.

// webserver.js

const path = require('path');
const axios = require('axios');
const express = require('express');
const template = require('./assets/index.template');

const app = express();

app.use(
    '/bundle.js',
    express.static(path.join(__dirname, 'build', 'bundle.js')),
);

app.get('/', function(req, res) {
    axios
        .post('http://localhost:8080/batch', {
            mysheep: {
                name: 'Sheep',
                data: {},
            },
        })
        .then(response => {
            const mysheep = response.data.results.mysheep.html;
            const renderedMarkup = template(mysheep);
            res.send(renderedMarkup);
        });
});

app.listen(22222, () => {
    console.log('Server listening on port 22222!');
});

We also need to update index.jsx to let Hypernova hook into the rendered components instead of using ReactDOM.render

// index.jsx

import { renderReact } from 'hypernova-react';
import Sheep from './sheep';

// All instances of 'Sheep' on the page will be hydrated by Hypernova with this
renderReact('Sheep', Sheep);

Fingers crossed - let’s run yarn start to compile webpack, launch Hypernova and start the webserver and hopefully…

Hooray! It’s exactly the same, but slightly quicker!

Stay tuned for performance tweaks and optimizations!

Checkout the example repo for all the code.

References

Discuss on TwitterEdit on GitHub