Adding a server-side rendering support for an existing React application using Express and Webpack

When we start building our project, we always start being fine with client side rendering. However, there comes a time when we need to render it in the server-side for many reasons — performance, better search engine crawling (SEO) etc.

There are lots of tutorials and solutions for SSR but I honestly do not like the approach they all are taking. Ready to run solutions such as NextJS are usually opinionated which ends up being a painful experience to add certain features. Most guides that I have followed throughout the web are either way too complicated or just not “one-command deployable.” So, in this guide, I will introduce a small existing application and add SSR support for it.

Our existing application

To be able to provide a good understanding of SSR, let’s create a very basic React application that runs on webpack-dev-server. We will add SASS support as extra.

Basic project structrure:

Here is our very basic project structure

/package.json
/postcss.config.js
/webpack.config.js
/config
/development.js
/src
/components/app
/App.js
/app.scss
/client
/index.js
/index.html

External libraries

These are the libraries that we need for our basic application to work:

# Yarn:
yarn add --dev babel-core babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-0 css-loader file-loader node-sass postcss-loader sass-loader style-loader url-loader
yarn add react prop-types react-dom webpack webpack-dev-server
# NPM
npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react babel-preset-stage-0 css-loader file-loader node-sass postcss-loader sass-loader style-loader url-loader
npm install prop-types react react-dom webpack webpack-dev-server

Webpack and PostCSS

Here is our very basic webpack configuration (config/development.js):

const path = require('path');
module.exports = {
entry: path.resolve(__dirname, '..', 'src/client/index.js')
output: {
path: path.resolve(__dirname, '..', 'dist'),
publicPath: '/dist/',
filename: 'client.js'
},
devServer: {
contentBase: path.resolve(__dirname, '..', 'src/client'),
publicPath: '/dist/'
},
resolve: {
extensions: ['.js'],
alias: {
components: path.resolve(__dirname, '..', 'src/components'),
}
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
query: {
presets: ['es2015', 'react', 'stage-0']
}
},
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
},
{
test: /\.(ttf|eot|otf|svg|png)$/,
loader: 'file-loader'
},
{
test: /\.(woff|woff2)$/,
loader: 'url-loader'
}
]
}
};

Since we are holding all our webpack config under config directory, we still need a webpack.config.js for webpack-dev-server. I am sure there is a way to set config file for webpack-dev-server but I haven’t bothered with it:

const developmentConfig = require('./config/development.js');
module.exports = developmentConfig;

And here is PostCSS configuration for autoprefixer:

module.exports = {
plugins: {
autoprefixer: {}
}
};

Client entrypoint

Here are the files that are necessary for webpack-dev-server:

client/index.html

<!DOCTYPE html>
<html>
<head>
<title>test app</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
</head>
<body>
<div id="app"></div>
<script src="/dist/client.js"></script>
</body>
</html>

client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from 'components/app/App';
ReactDOM.render(<App />, document.getElementById('app'));

App component

Now our very basic app component. Let our app just show an <h1> element:

components/app/App.js

import React, { Component } from 'react';
import './app.scss';
export default class App extends Component {
render() {
return (
<div className="app">
<h1 className="app-heading">Hello World</h1>
</div>
);
}
}

components/app/app.scss

.app-heading {
color: red;
}

Building our SSR app

I didn’t get into details of our basic app as I tried to keep the configuration and everything at a minimum level and spend more time talking about SSR. So, let’s get started.

The premise behind SSR

The main function of server-side rendering is to take existing client-side code and render it in the server-side with the smallest amount of modifications. Most of the time these modifications are necessary for external libraries such as React Router due to the differences in the environment.

SSR Webpack configuration

As usual, we need a new Webpack configuration file that is separate from our development config. We will name our file server.production.js since it will be responsible for building our server application in production mode. For now, copy paste development config file to server production config file. Once we copied the dev config into server config, we need to make modifications (I will show the final output at the end of this section):

  • We need to intall webpack-node-externals package. This package is necessary for ignoring node_modules during build process. Once we install it, let’s include it in our new webpack config:
const nodeExternals = require('webpack-node-externals');
  • target: The default build target in Webpack is web, which is used for browser environments. Since we are in node environment, let’s change the target to node.
  • externals: Now it is time to ignore node_modules. Add a new parameter to webpack in the following way: externals: [nodeExternals()]
  • entry: We have not created the file but we need a new entry for SSR. We will store our server entry under src/server/index.js. So, let’s add it to our config: entry: path.resolve(__dirname, ‘..’, ‘src/server/index.js’)
  • output: This is the most important part for our configuration. path and publicPath parameters will stay the same. We will set the filename to “server.js” to know that the output file is server.js. Additionally, we will add two more parameters to our path: library and libraryTarget. library parameter will expose our entry file’s exports to the “outside world.” We are going to call our library name app. libraryTarget parameter defined how our library is exported to the outside world. Since module.exports is a default in node environment, we are going to use the same. Set libraryTarget to commonjs2.
  • module: We want our server application to not generate any asset for us. This is because I think these assets should be generated during building the client application phase. So, how do we do that? For file-loader and url-loader append ?emitFile=false to the loader. For style loader, replace the entire loader with css-loader/locals. If you use some other loader, I would recommend checking the documentation regarding not emitting the files.

Let’s wrap it all together. Our final configuration looks like the following:

const nodeExternals = require('webpack-node-externals');
const path = require('path');
module.exports = {
target: 'node',
externals: [nodeExternals()],
entry: path.resolve(__dirname, '..', 'src/server/index.js'),
output: {
path: path.resolve(__dirname, '..', 'dist'),
publicPath: '/dist/',
filename: 'server.js',
library: 'app',
libraryTarget: 'commonjs2'
},
resolve: {
extensions: ['.js'],
alias: {
components: path.resolve(__dirname, '..', 'src/components')
}
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
query: {
presets: ['es2015', 'react', 'stage-0']
}
},
{
test: /\.scss$/,
loader: 'css-loader/locals'
},
{
test: /\.(ttf|eot|otf|svg|png)$/,
loader: 'file-loader?emitFile=false'
},
{
test: /\.(woff|woff2)$/,
loader: 'url-loader?emitFile=false'
}
]
}
};

Server entry file

The server entry file’s main purpose is to serve a function that renders everything needed for SSR. At this time, we will only need ReactDOMServer but if you happen to use other libraries such as react-helmet, react-router, you will add their SSR functionalities in this function.

Why do we need a function? Remember we added library in webpack with name app? The reason for this parameter was to be able to access our SSR React app as a library. Since we will be using express, it is best to serve a function so that our express app can access it.

Here is what our src/server/index.js looks like:

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import templateFn from './template';
import App from 'components/app/App';
export default (req, res) => {
const html = ReactDOMServer.renderToString(
<App />
);
    const template = templateFn(html);
    res.send(template);
};

The templateFn function returns an HTML document (src/server/template.js):

export default (html) => `
<!DOCTYPE html>
<html>
<head>
<title>test app</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
</head>
<body>
<div id="app">${html}</div>
<script src="/dist/client.js"></script>
</body>
</html>
`;

Anything that we add in our server application will reside in these two files. Let’s say you need to add React Helmet and you need to add HTML head related tags in SSR. You will just add helmet argument to our templateFn function and assign the variables like ${helmet.title.toString()} in the head tag.

The express server

Now onto actually creating our express server so that we can run our React application in server environment.

Dependencies

Before we get into coding our server app, we need to add dependencies into our project:

# Yarn
yarn add require-from-string express memory-fs
# NPM
npm install --save require-from-string express memory-fs

express is needed to create our server application. require-from-string is needed to load modules by reading contents of a string. memory-fs is needed to store our created server bundle in the memory instead of filesystem.

Custom script and NPM command to run the script

Let’s createserver.js file in the project’s root directory and add the following to package.json script parameter:

"ssr": "NODE_ENV=production node ./server.js"

This will allow us to run our server using a single command:

# Yarn
yarn run ssr
# NPM
npm run ssr

Compiling Webpack bundle from custom script and running our server

The beautiful thing about webpack is that, it can be run from inside a script using webpack node library. So let’s dive into coding:

const express = require('express');
const webpack = require('webpack');
const path = require('path');
const requireFromString = require('require-from-string');
const MemoryFS = require('memory-fs');
const serverConfig = require('./config/server.production.js');
const fs = new MemoryFS();
const outputErrors = (err, stats) => {
if (err) {
console.error(err.stack || err);
if (err.details) {
console.error(err.details);
}
return;
}

const info = stats.toJson();
if (stats.hasErrors()) {
console.error(info.errors);
}
    if (stats.hasWarnings()) {
console.warn(info.warnings);
}
};
console.log('Initializing server application...');
const app = express();
console.log('Compiling bundle...');
const serverCompiler = webpack(serverConfig);
serverCompiler.outputFileSystem = fs;
serverCompiler.run((err, stats) => {
outputErrors(err, stats);
const contents = fs.readFileSync(path.resolve(serverConfig.output.path, serverConfig.output.filename), 'utf8');
const app = requireFromString(contents, serverConfig.output.filename);
    app.get('*', app.default);
app.listen(3000);
    console.log('Server listening on port 3000!');
});

This is all for our server application to work with React. I am going to go through important parts to talk about what we did.

outputErrors function just outputs all the errors and warnings that webpack may have. I think I found that piece of gem in webpack’s examples and really liked how it covers all the errors and warnings.

const serverCompiler = webpack(serverConfig);
serverCompiler.outputFileSystem = fs;

These two lines of code create a webpack compiler and emit the output file into memory-fs. MemoryFS is a filesystem API that is completely stored in memory. It uses the same APIs as Node’s own fs API; therefore, it is easy to just replace webpack’s filesystem with this filesystem.

serverCompiler.run((err, stats) => { ... });

This line actually runs the compiler to compile the webpack bundle asyncronously.

const contents = fs.readFileSync(path.resolve(serverConfig.output.path, serverConfig.output.filename), 'utf8');
const app = requireFromString(contents, serverConfig.output.filename);

Since we know the exact location of the file in webpack configuration, we can just create the same file path. The first line in this snippet reads the file syncronously and loads it into contents variable. The second line creates a module using require-from-string library and the contents that we read from memory-fs. Again, remember we talked about library parameter in webpack and exported function in src/server/index.js? As a result, loading a module using require will return an object in the following way:

{ app: { default: serverIndexDefaultExportFunction } }

Since we are using require-from-string library, the app part is assigned without an object. So, the module actually returns:

{ default: serverIndexDefaultExportFunction }

Since we can access the function that we exported in src/server/index, we just assign that function to our express callback:

app.get('*', app.default);
app.listen(3000);

Now if you run:

# Yarn
yarn run ssr
# NPM
npm run ssr

You will get a server that shows the needed content.

Conclusion

The purpose of this guide was to show that building an SSR application is not a convoluted process. Separating express server from the React server-side application provides a much better developing experience than making express application part of the webpack process.

There are lots of boilerplates and libraries that provide SSR but considering how simple it is to setup your SSR, most boilerplates or a libraries are actually harder to customize.

What’s next?

For this guide, I did not cover building the client side of the application as I believe that it shouldn’t be part of SSR building process. In an ideal environment, there needs to be a build server that creates both the client and server (e.g using Docker) and deploys the to their respective locations (S3 for client, Docker image for server).

If you want to create client in the SSR server, I recommend creating a client.production.js config file and adding another webpack compiler in the express server application that uses this configuration.

This guide only provides a template that can be further enhanced by adding full development support using hot middleware, webpack watches etc.