Skip to content
George Michael Faust edited this page Mar 30, 2017 · 2 revisions

Purpose of SSR:

  • SEO: search engine indexing
  • sharing (facebook linter)
  • accessibility: (semantic html, app works without Javascript)
  • performance: rendering app and making requests is faster on server than on slow devices

express server

First thing's first, if we want server-side rendering, we need a server. There are lots of options (Hapi Koa, Sails...), but we are going to use Express for our app.

First install express and create a new file.

yarn add express
touch server.js

Very basic express server.

const express = require('express')

const app = express()
app.use('*', (request, response) => {
	response.send('this was sent from my express server')
})

app.listen(8080)

Now run node server in your command line, and navigate to localhost:8080 in a web browser.

server.js step-by-step:

  • require express (imports are not supported in node)
  • create an app by calling express()
  • .use() method on app allows us to define a request handler for a route, we have specified '*' for the route, this will catch all requests (we could also omit this argument, to achieve the same effect)
  • the second argument to .use is a callback function, express injects request and response objects into the callback
  • the .send() method on the response object sends data (string or buffer) to the client
  • app.listen() starts the server listening on the port passed to it

Now that we have a server, we want to start serving our app. Webpack writes our compiled application (html file and static assets) to our build dir.

The paths to static assets generated by webpack in index.html are relative to the build dir (they begin with /static), so any requests that begin with /static we want to map to files in ./build/static. Express has a convenience function for serving static files from a directory.

Remember to build first.

yarn build

app.use('/static', express.static('./build/static'))

For requests to the root of our application, we want to serve our index.html. Express has a convenience function for loading and sending specific files, but it requires an absolute path to the file.

const path = require('path')
...
app.use('/', (request, response) => {
  response.sendFile(path.join(__dirname, 'build', 'index.html'))
})

We are now serving our app.

complete code for server.js

const express = require('express')
const path = require('path')

const app = express()

app.use('/static', express.static('./build/static'))
app.use('/', (request, response) => {
  response.sendFile(path.join(__dirname, 'build', 'index.html'))
})

app.listen(8080)

Update package.json to lint server.js and start the server on yarn start

package.json

  "scripts": {
    "build": "rimraf build/ && webpack -p",
    "dev": "webpack-dev-server --config webpack.config.dev.js",
    "lint": "eslint --fix src/ server.js",
    "start": "export NODE_ENV=production && node server"
  },

Setting NODE_ENV to production, will help speed up React. Webpack does this for the client code when we add -p.

html template

Serving the index file generated by webpack does not allow us to inject anything dynamic (like server-rendered html). For that, we need a template.

Again, there are many possible solutions, (pug, dust, hogan...), but the simplest solution is to use an ES6 template literal.

touch src/index.html.js

module.exports.htmlTemplate = () => ``

Then copy and paste the contents of src/index.html in between the backticks.

module.exports.htmlTemplate = () => `<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://unpkg.com/tachyons/css/tachyons.min.css">
  <title>react app</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>`

We can include this file and send the return value of htmlTemplate to the client.

const { htmlTemplate } = require('./src/index.html.js') // include .js so it doesn't default to the .html file
...
app.use('/', (request, response) => {
  response.send(htmlTemplate()) // .send instead of .sendFile
})

But we need to inject the paths to our .js and .css files into the template. The problem is everytime we build, the hashes (and therefore the names) of our files change. There is a plugin for webpack that will list all the files generated from the build.

yarn add webpack-manifest-plugin --dev

webpack.config.js

const ManifestPlugin = require('webpack-manifest-plugin')
...
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      sourceMap: true,
      minimize: true,
      output: {
        comments: false
      }
    }),
    new ExtractTextPlugin({
      filename: '/static/[name].[chunkhash].css' 
    }),
    // we don't need html plugin anymore
    new ManifestPlugin(),
  ]

We can require and read from the manifest.json in our server.

const manifest = require('./build/manifest.json')
manifest['main.js] // path to main js file
manifest['main.css'] // path to main css file

We need to update the html template to use the paths.

src/index.html.js

module.exports.htmlTemplate = ({ cssPath, jsPath }) => `<!doctype html>
...
 <link rel="stylesheet" href="${cssPath}">
 ...
 <script src="${jsPath}"></script>

server.js

app.use('/', (request, response) => {
  response.send(htmlTemplate({
    jsPath: manifest['main.js'],
    cssPath: manifest['main.css'],
  }))
})
yarn build
node server

We are serving our app again.

preparing for isomorphic javascript

So far, webpack and the babel transpiler have allowed us to write some very fancy javascript in our application. Even the most current version of node will not support imports or JSX. If we require almost any part of our application in server.js, it will throw.

const App = require('./src/components/app/app')

Luckily there are tools that allow us to transpile files we require in node at runtime.

yarn add babel-register

babel-register should be required at the very top of server.js to intercept and, if necessary, transpile any unsupported code.

babel-register exports a function that accepts a config object.

server.js

require('babel-register')({
  extensions: ['.js'],
  presets: ['es2015'],
})
  • extensions accepts an array of file-extensions (required files, with extensions that match any of the ones defined here, will be transpiled)
  • presets is for specifiying what babel presets to use (the same as the presets in a .babelrc file)

But it still breaks :(

Webpack has also been taking care of our .css imports. Node cannot deal with CSS, even babel-register cannot help us, we need to use the ignore-styles package.

yarn add ignore-styles

server.js

require('ignore-styles')

ignore-styles by default it ignores all .css imports, but you can customize it to ignore other file types, if need be.

example:

require('ignore-styles').default(['.scss', '.less'])

rendering the app

Our react application needs to have a separate entry point on the server. To setup the entry point in server.js, we need to import all the pieces of our app.

First we need, the non-dom version of react-router v4.

yarn add react-router@next

On the server, we can't use BrowserRouter (it reads from and manipultates DOM-specific APIs), instead, we need StaticRouter. StaticRouter does not touch any browser-specific APIs, so you have to pass the path to it's location prop. It also cannot handle any navigation, it will mutate the context object you pass to it, if any of it's routes triggers a redirect.

example:

const context = {}
const appEntry = createElement(
	StaticRouter, { location: '/path/to/route', context }, createElement(
		App))
renderToString(appEntry)
if (context.url) {
	// handle redirect
}

We need to import StaticRouter from react-router, the Provider component from react-redux, and, because we can't use JSX, createElement from react.

server.js

const { StaticRouter as Router } = require('react-router')
const { Provider } = require('react-redux')
const { createElement } = require('react')

We need our App component, and our store.

const App = require('./src/components/app/app').default // .default because require doesn't automatically pass the default export
const store = require('./src/store').default

And, to render our application, because we can't use ReactDom.render, we need renderToString.

const { renderToString } = require('react-dom/server')

Bootstrapping our app, should look similar to what is in index.js, minus the JSX syntax.

server.js

function bootstrapApp(location) {
  const appEntry = createElement(
    Provider, { store }, createElement(
      StaticRouter, { location, context: {} }, createElement(
        App)))
  
  return renderToString(appEntry)
}

renderToString

In index.js, to bootstrap our app, we call ReactDOM.render(). ReactDOM.render() takes two arguments, the entry point of our application (usually JSX), and a reference to a DOM element.

ReactDOM.render(
  <Provider store={store}>
    <Router>
      <App />
    </Router>
  </Provider>,
  document.getElementById('root'))

It converts our entry point to DOM nodes and injects them into the root element passed in, as the second argument.

On the server, we don't have the dom, and cannot get a reference to an element by calling document.getElementById (there is no document). We need to send the HTML to the client as a string.

renderToString loops recursively through our components, rendering the app and all the child components to a string, that we can then inject into our HTML template.

server.js

app.use('/', (request, response) => {
  const appHTML = bootstrapApp(request.url) // request.url has the path requested from the server 
  response.send(htmlTemplate({
    jsPath: manifest['main.js'],
    cssPath: manifest['main.css'],
    appHTML,
  }))
})

src/index.html.js

module.exports.htmlTemplate = ({ cssPath, jsPath, appHTML }) => `<!doctype html>
...
	<div id="root">${appHTML}</div>

If we build and run, everything seems fine, until we navigate to localhost:8080... The server throws an error: fetch is not defined. renderToString not only calls the render method of every component, it also triggers componentWillMount.

componentWillMount in src/components/card-filter-view.js calls getRobots and in src/actions/index.js, getRobots makes a fetch request. Unfortunately, the fetch API only exists in browsers. We need a polyfill, so that it will not throw in node.

yarn add isomorphic-fetch

src/actions/index.js

import 'isomorphic-fetch'

Build and start the server again, navigate to localhost:8080 and "View Source"... it works!

store

Currently, the store is created inside of src/store/index.js. There is a single store in memory scoped outside of our request handler. On the server, every request is sharing the same store. When a user makes a request, the store already has all the data from every other user's requests. If there was login information, or user-specific sensitive data in our app, this would be a huge problem. The store should be scoped to the request handler.

The easiest way to defer calling createStore is to wrap the export in a function.

src/store/index.js

export default () => createStore(appReducer, applyMiddleware(reduxThunk))

Now we need to update our app entry points.

server.js

const storeFactory = require('./src/store').default
...
function bootstrapApp(location, store) {
...
const store = storeFactory()
const appHTML = bootstrapApp(request.url, store)

src/index.js

import storeFactory from './store'
...
<Provider store={storeFactory()}>

src/index.dev.js

import storeFactory from './store'
...
<Provider store={storeFactory()}>

The store is now scoped to the request handler.

async requests

As we have seen renderToString is triggering the request to the API, and it is even hydrating the store when the request completes. But the server is not waiting for any of that to happen before returning the HTML to the client.

There are a lot of ways to trigger and wait for async requests on the server, but we already know that the request is firing, and that the store is being updated, so we can piggy-back on what we already have, by subscribing to the store.

server.js

app.use('/', (request, response) => {
  const store = storeFactory()
  const unsubscribe = store.subscribe(() => {
    const state = store.getState()
    if (!state.robotData.isPending) {
      unsubscribe()
      const appHTML = bootstrapApp(request.url, store)
      response.send(htmlTemplate({
        jsPath: manifest['main.js'],
        cssPath: manifest['main.css'],
        appHTML,
      }))
    }
  })

  bootstrapApp(request.url, store)
  store.dispatch({ type: 'INIT_SSR' })
})

step-by-step

  • create the store

  • subscribe to store updates, passing a handler function that will be called after everytime the store handles an action

  • inside the handler

    • get the current state
    • check the state to see if we are waiting for a request
    • if we are not waiting...
      • unsubscribe
      • bootstrap the app (with hydrated state)
      • render the template
      • send the response to the client
  • after registering the handler

    • bootstrap the app, to trigger the request
    • dispatch an empty event (just to make sure the subscribe handler gets called once)

hydrating the store

The server is now returning html rendered with the results of our API request, but when the app bootstraps on the client, it doesn't have any of that data, it even makes the request again and re-hydrates the store. We need to pass the hydrated store down to the client.

Our only option is to stringify the data and attach it the window object.

Pass the state to the html template.

server.js

response.send(htmlTemplate({
	jsPath: manifest['main.js'],
	cssPath: manifest['main.css'],
	appHTML,
	state,
}))

In the template, stringify and attach it to window. src/index.html.js

module.exports.htmlTemplate = ({ cssPath, jsPath, appHTML, state }) => `<!doctype html>
...
<script>
	window.INITIAL_STATE = ${JSON.stringify(state).replace(/</g, '\\u003c')}
</script>
<script src="${jsPath}"></script>

In the store, read INITIAL_STATE from window (if window exists), and pass it to createStore.

src/store/index.js

let initialState

try {
  initialState = window.INITIAL_STATE
  delete window.INITIAL_STATE
} catch (_) {
  initialState = undefined
}
export default () => createStore(appReducer, initialState, applyMiddleware(reduxThunk))

The client app is still requesting the data a second time. We need to make the action check if it already has the data before sending the request.

src/actions/index.js

export const getRobots = () => (dispatch, getState) => {
  const state = getState()
  if (state.robotData.robots.length >= 10) {
    return
  }

Rebuild and start the server. Navigate to localhost:8080 all the robots are there, but the client is no longer making it's own request to the API.

You can even disable javascript, and the entire app will still work!

caching

As nice as it is to be sending all this semantic html to the client. With the wait for the API request to return and calling renderToString twice, the server response time is a lot slower than it was when we were calling response.send immediately.

The individual pages of our app don't change much, instead of making a new request for data every time a client requests a page, we can cache the rendered html.

We can use a library called lru-cache for simple, in-memory caching.

yarn add lru-cache

We just need to import the library and, and instantiate a cache object.

server.js

const LRU = require('lru-cache')
...
const cache = LRU({
  max: 11,
  maxAge: 3600000,
})

The max option lets us set the size of the cache. The number (11), by default, represents the number of items, but it can be overriden by defining a length function. The maxAge option is how long to store each item (in milliseconds), it won't actively delete items when the time is up, instead it checks the age before returning a requested item.

We can add items to the cache, in our request handler.

server.js

const htmlResponse = htmlTemplate({
	jsPath: manifest['main.js'],
	cssPath: manifest['main.css'],
	appHTML,
	state,
})
response.send(htmlResponse)
cache.set(request.url, htmlResponse)

Now we can define a new request handler.

function checkCache(request, response, next) {
  if (cache.has(request.url)) {
    response.send(cache.get(request.url))
    return
  }

  next()
}

And extract our SSR handler into a separate, named, function.

function handleSSRRequest (request, response) {
  const store = storeFactory()
  ...
}
...
app.use('/', handleSSRRequest)

Similar to the path attribute in RRV4, if we omit the route, it will catch all requests.

app.use(handleSSRRequest)

In express, request handlers are called, for routes they match, in the order they are defined. Express passes a next callback as the third parameter to request handlers, if next is called, it will pass the handling of the request onto the next handler.

example:

app.use('*', function (request, response, next) {
	if (!request.body.secret_token) {
		response.status(403).send('you are not authorized')
		return
	}
	next()
})
app.use('*', function (request, response) {
	response.send('top secret')
})

So we need only to register the checkCache handler before our SSR handler.

server.js

app.use(checkCache)
app.use(handleSSRRequest)

Notes on caching:

Simple, in-memory caching works for our site, because it is only eleven pages, and there is no user-specific content. If your site has any kind of login, or you are serving user-specific data, caching entire pages won't be very useful, and you'll have to look into caching individual components like walmart. If you have an e-commerce site with millions of products, your cache will not fit in memory, and you'll have to use a storage service (like S3), and request cached items/pages from it.

handling redirects

If we want to serve a user the main page when they navigate to a route that doesn't exist on the client, we might want the server to also serve them the main page when they request from it, a route that doesn't exist.

StaticRouter will not trigger a re-render if it runs into a redirect. Instead, it updates the context object passed into it. To handle redirects server-side, we have to check if the context object has been mutated. First, let's pass a context object, that we have a reference to, to StaticRouter and then return it from bootstrapApp.

server.js

function bootstrapApp(location, store) {
	const context = {}
	...
		StaticRouter, { location, context }, createElement(
	...
	const appHTML = renderToString(appEntry)
	return { appHTML, context }
}

And update handleSSRRequest to deal with this change.

const { appHTML } = bootstrapApp(request.url, store)

Now we can check the context after the initial bootstrap.

const { context } = bootstrapApp(request.url, store)
if (context.url) {
	...
}

If the router ran into a redirect, unsubscribe and redirect the client.

if (context.url) {
	response.redirect(context.url)
	unsubscribe()
	return
}

And that's it.

Conclusion:

SSR with react, redux and react-router V4 is not too difficult. But performance, especially for a large site with lots of traffic, becomes very difficult.

The best resource I have found on SSR performance is this talk by Sasha Aicken. Infernojs has also made strides in improving SSR performance.

server.js in it's entirety

require('babel-register')({
  extensions: ['.js'],
  presets: ['es2015'],
})
require('ignore-styles')
const express = require('express')
const LRU = require('lru-cache')
const { createElement } = require('react')
const { StaticRouter } = require('react-router')
const { Provider } = require('react-redux')
const { renderToString } = require('react-dom/server')

const { htmlTemplate } = require('./src/index.html.js')
const manifest = require('./build/manifest.json')
const App = require('./src/components/app/app').default
const storeFactory = require('./src/store').default

const app = express()
const cache = LRU({
  max: 11,
  maxAge: 3600000,
})

function bootstrapApp(location, store) {
  const context = {}
  const appEntry = createElement(
    Provider, { store }, createElement(
      StaticRouter, { location, context }, createElement(
        App)))
  const appHTML = renderToString(appEntry)
  return { appHTML, context }
}

function checkCache(request, response, next) {
  if (cache.has(request.url)) {
    response.send(cache.get(request.url))
    return
  }

  next()
}

function handleSSRRequest(request, response) {
  const store = storeFactory()
  const unsubscribe = store.subscribe(() => {
    const state = store.getState()
    if (!state.robotData.isPending) {
      unsubscribe()
      const { appHTML } = bootstrapApp(request.url, store)
      const htmlResponse = htmlTemplate({
        jsPath: manifest['main.js'],
        cssPath: manifest['main.css'],
        preloadChunks: [manifest['profile.js']],
        appHTML,
        state,
      })
      response.send(htmlResponse)
      cache.set(request.url, htmlResponse)
    }
  })

  const { context } = bootstrapApp(request.url, store)
  if (context.url) {
    response.redirect(context.url)
    unsubscribe()
    return
  }
  store.dispatch({ type: 'INIT_SSR' })
}

app.use('/static', express.static('./build/static'))
app.use(checkCache)
app.use(handleSSRRequest)

app.listen(8080)
Clone this wiki locally