React with Server Side Rendering and Code Splitting

October 12, 2020

Contents

Overview

React is obviously a very javascript centered web framework. This means your site's HTML will be dynamically generated by JS itself.

While generating a site with React can be useful, putting the burden on the client has its downsides: requires js, slower load time, and missing content on initial load.

I think the missing content on the initial load has the biggest impact on websites because of Search Engine Optimization (SEO). When search engines like Google crawl your site, they may not run javascript to gather crucial info like headers and content.

Luckily there are ways to pre-render your React website on the server, get the initial HTML and to send that to the frontend along with all the javascript.

In my experience Server Side Rendering (SSR) can get complicated, especially as your site grows and uses more features. One feature that can cause problems is React lazy loading components for code splitting. Code splitting lets you split up a large bundle.js file webpack outputs into seperate files that are downloaded when needed by the client.

What I'll lay out is how I setup my React, Express, and Webpack code to handle server side rendering a React application that also uses code splitting.

The final code can be viewed here.

Client Side

Client side, we need to use Loadable Components instead of React.lazy, since React.lazy doesn't work with server side rendering.

First import loadable,

npm install --save-dev @loadable/component

then for any components you want split off into seperate files, import them with loadable.

import loadable from '@loadable/component';

const ChildComponent = loadable(() => import('./ChildComponent.js'));

Finally go to the index.js, or whatever file has ReactDOM.render in it and replace React.render with React.hydrate and then wrap in loadableReady.

import React from 'react';
import ReactDOM from 'react-dom';
import { loadableReady } form '@loadable/component';

import App from './components/App.js' // or wherever your root component lives

loadableReady(() => {
  ReactDOM.hydrate(
    <App />,
    document.getElementById('root')
  );
});

ReactDOM.hydrate is used instead of ReactDOM.render becauseh the HTML React will be acting on will already have all the components loaded, and hydrate will fill in any missing pieces.

Server Side

Server side, we need to import the root React component, build it with ReactDOMServer, and insert it into our index.html file.

To manage the code chunks loadable creates we need a new Loadable Component package.

npm install --save @loadable/server

Now to render the React code to HTML, we need to read in a stats file the client webpack build will generate. This will tell loadable what different chunks there are.

import express from 'express';
import fs from 'fs';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { ChunkExtractor } from '@loadabe/server';

import App from '../client/components/App.js' // or wherever your toot component lives

const statsFile = path.resolve('dist/loadable-stats.json');
const extractor = new ChunkExtractor({ statsFile });
const html = ReactDOMServer.renderToString(extractor.collectChunks(<App />));
const scriptTags = extractor.getScriptTags();
const styleTags = extractor.getStyleTags();

With this generated html string that contains all the React rendered HTML, we can inject it into the index.html file and serve that to the client.

let prerenderedHTML;
fs.readFile('public/index.html', 'utf8', (err, data) => {
  if (err) {
    console.error(`Something went wrong reading index.html:\n${err}`);
  }

  prerenderedHTML = data.replace(/<div id="root""><\/div>/, `<div id="root">${html}</div>`)
    .replace(/<\/head>/, `${scriptTags}</head>`)
    .replace(/<\/head>/, `${styleTags}</head>`);
});

const app = express();

app.get('/', (req, res) => {
  res.send(prerenderedHTML);
});

The complete express server code can be viewed here.

Webpack Configs

Server side rendering a React application means that vanilla Node.js isn't going to cut it. The server will need to import and execute all the React code the client builds using Webpack.

First, we need two final Loadable Component package so webpack and babel can handle loadable.

npm install --save-dev @loadable/webpack-plugin @loadable/babel-plugin

In the .babelrc add @loadable/babel-plugin to plugins.

Now there are ways to avoid using webpack to build server code like running it with babel-node and ignore css file imports, but it seems to me that all the logic to build the client code already exists in the webpack config. Might as well use it for the server as well.

We need two webpack configs, one with an entrypoint to build the client code and one with an entry point for the server code. To keep all the config in one file, I just export an array of webpack config objects from the webpack.config.js file.

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const LoadablePlugin = require('@loadable/webpack-plugin');

const path = require('path');

const PROD_ENV = process.env.NODE_ENV === 'production';

const commonConfig = {
  plugins: [
    new MiniCssExtractPlugin(),
    new LoadablePlugin()
  ],

  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /(node_modules|bower_components)/,
        loader: 'babel-loader',
        options: { presets: ['@babel/env'] }
      },
      {
        test: /\.s?[ac]ss$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
      },
      {
        test: /\.(png|jpg|svg|gif)$/,
        loader: 'file-loader'
      }
    ]
  },
};

const clientConfig = {
  entry: path.join(__dirname, 'src/client/index.js'),
  mode: PROD_ENV ? 'production' : 'development',

  resolve: { extensions: ['*', '.js', '.jsx'] },

  optimization: {
    minimize: PROD_ENV ? true : false
  },

  output: {
    filename: '[name].[chunkhash].bundle.js',
    path: path.resolve(__dirname, 'dist/'),
  },

  devtool: 'source-map'
}

const serverConfig = {
  entry: path.join(__dirname, 'src/server/server.js'),
  mode: PROD_ENV ? 'production' : 'development',
  target: 'node',

  resolve: { extensions: ['*', '.js', '.jsx'] },

  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, 'src/server/dist/'),
  },
};

module.exports = [
  Object.assign({}, commonConfig, clientConfig),
  Object.assign({}, commonConfig, serverConfig),
];

A lot of the config stuff is just how I like it set up. The important bits are the LoadablePlugin to both client and server config, MiniCssExtractPlugin for splitting css files, and the entry/output values for each config. The entry for the client config should be the js file with the loadableReady and ReactDOM.hydrate code. The entry for the server should be the file you would normally run the server with like node server.js.

Again the final code for all of this can be found here. The repository is for my own personal use, so there are some additional features like webpack dev server, and sass-loader. But it's still barebones, and could be modified easily for different uses.