You might (not) need a Server Side Rendering framework

tools-barn
2017-09-12-you-might-(not)-need-a-server-side-rendering-framework

TL;DR: a full-working repository is available here

At my daily job at brigad.co, I have been using React, React-Router and Webpack for quite some time. When we needed to improve our SEO, so we could rank higher in search engines, Server Side Rendering came as an obvious choice. So I started playing around with some of the most popular frameworks. To name two:

I would definitely recommend you give them a try if you haven't yet. Just like most people would use create-react-app to start a client side project, they are great tools to quickly start a new project, while having an awesome community around them.

However, after trying the two for a week or so, I felt like the restrictions which come with a framework would led to too much refactoring, and that it didn't really suit our team. So I went and implemented my own solution, using (almost) only Webpack.

Disclaimer: my goal here is to share my experience trying to implement my own solution, and really not to take side on which solution is best. Pick what works for you!

What is Server Side Rendering?

First, let's start by explaining what is Client Side Rendering: your server sends an HTML page to the client, with an empty DOM and a script tag which will load your React bundle. The client's browser will parse the React code, transform it into HTML, and inject it in the DOM.

Based on their network and CPU, the client could wait ages to see any content

With Server Side Rendering, your bundle is first transformed into HTML on the server, which is directly sent to the client. But the client's browser will still check that the output from the JS bundle is the same as the HTML the server just sent (it should always be).

The client sees the content instantly, instead of having to wait for the Javascript to be parsed

Server Side Rendering improves loading times, but also search engine ranking, as it will be easier for them to crawl your site. Note that Google should be able to properly crawl sites build with React by now (but for some reason, it was still seeing our site as a blank page before we introduced Server Side Rendering - How to see how your website looks like to Google).

Requirements

So, let's start with the requirements, based on the needs of our project:

Code splitting / Async chunk loading on the client

I want to start by giving credit to Emile Cantin for his post, which helped me a lot on the subject.

What do we want here?

On the client, we want code splitting with async chunk loading, so that the client only downloads chunks which are essentials for the current view. On the server, we want only one bundle, but when rendering we will store the names of the chunks which will later be needed on the client.

This is done with the two following HOCs:

const asyncComponent = getComponent => {
  class AsyncComponent extends React.Component {
    static preloadedComponent = null;

    static loadComponent = async () => {
      const module = await getComponent();
      const Component = module.default;

      AsyncComponent.preloadedComponent = Component;

      return Component;
    };

    constructor(props) {
      super(props);

      this.state = {
        Component: AsyncComponent.preloadedComponent,
      };

      this.mounted = false;
    }

    async componentWillMount() {
      if (!this.state.Component) {
        const Component = await AsyncComponent.loadComponent();

        if (this.mounted) {
          this.setState({ Component });
        }
      }
    }

    componentDidMount() {
      this.mounted = true;
    }

    componentWillUnmount() {
      this.mounted = false;
    }

    render() {
      const { Component } = this.state;

      if (!Component) {
        return null;
      }

      return <Component {...this.props} />;
    }
  }

  return AsyncComponent;
};

asyncComponent.js

The client version asynchronously loads a component and renders it when it is ready.

const syncComponent = (chunkName, mod) => {
  const Component = mod.default ? mod.default : mod;

  const SyncComponent = ({ staticContext, ...otherProps }) => {
    if (staticContext.splitPoints) {
      staticContext.splitPoints.push(chunkName);
    }

    return <Component {...otherProps} />;
  };

  SyncComponent.propTypes = {
    staticContext: PropTypes.object,
  };

  SyncComponent.defaultProps = {
    staticContext: undefined,
  };

  return SyncComponent;
};

syncComponent.js

The server version renders a component synchronously, and stores its name in an array received in parameter. This parameter is implicitly passed by React-Router to each route component.

How do we use them?

The goal is to have a file (well, two actually) with the list of every route of our app.

export const MainLayout = asyncComponent(() =>
  import(/* webpackChunkName: "MainLayout" */ 'src/views/main-layout/js/MainLayout'),
);
export const Home = asyncComponent(() =>
  import(/* webpackChunkName: "Home" */ 'src/views/home/js/Home'),
);
export const Page1 = asyncComponent(() =>
  import(/* webpackChunkName: "Page1" */ 'src/views/page1/js/Page1'),
);
export const Page2 = asyncComponent(() =>
  import(/* webpackChunkName: "Page2" */ 'src/views/page2/js/Page2'),
);

AsyncBundles.js

We are using Webpack magic comments to name the chunks. It is a bit tedious to repeat the name of the chunk, but it is on the same line so it should not be hard to maintain.

export const MainLayout = syncComponent(
  'MainLayout',
  require('src/views/main-layout/js/MainLayout'),
);
export const Home = syncComponent('Home', require('src/views/home/js/Home'));
export const Page1 = syncComponent(
  'Page1',
  require('src/views/page1/js/Page1'),
);
export const Page2 = syncComponent(
  'Page2',
  require('src/views/page2/js/Page2'),
);

Bundles.js

And the same list, using the other HOC.

What do we do with the lists?

It is now time to define the structure of our app. Thanks to react-router-config, we can do it all at one place (this will be helpful for the server to know which routes can be rendered).

import { MainLayout, Home, Page1, Page2 } from './Bundles';

const RedirectToHome = () => <Redirect to="/" />;

const routes = (
  <Route component={MainLayout}>
    <Route exact path="/page1" component={Page1} />
    <Route exact path="/page2" component={Page2} />
    <Route exact path="/" component={Home} />

    <Route component={RedirectToHome} />
  </Route>
);

const getChildRoutes = childRoutes =>
  React.Children.map(
    childRoutes,
    ({ props: { exact, path, component, children } }) => ({
      exact,
      path,
      component,
      routes: children ? getChildRoutes(children) : children,
    }),
  );

const routesArray = [
  {
    exact: routes.props.exact,
    path: routes.props.path,
    component: routes.props.component,
    routes: getChildRoutes(routes.props.children),
  },
];

routes.js

With this awesome package, and the getChildRoutes function, we will be able to define our routes in a declarative way, and of course we can nest them as we please.

There is one little catch though: when we nest routes, parent routes must contain the following code so they can render their children routes:

import { renderRoutes } from 'react-router-config';

const MainLayout = ({ route: { routes } }) => <div>{renderRoutes(routes)}</div>;

MainLayout.js

How do we do the difference between the client and server versions?

With the use of webpack.NormalModuleReplacementPlugin! Client side, it will replace occurrences of Bundles with AsyncBundles.

const plugins = [
  new webpack.NormalModuleReplacementPlugin(
    /\/components\/Bundles/,
    './components/AsyncBundles',
  ),
  new webpack.NormalModuleReplacementPlugin(/\/Bundles/, './AsyncBundles'),
];

webpack.config.client.js

I declared the plugin twice because I import AsyncBundles from two different paths.

Putting the pieces together

For all of this to work together, we will create two entry points.

const render = manifests => (req, res) => {
  initializeServerSideHeaders(req.headers);

  const context = {
    splitPoints: [],
  };

  const markup = renderToString(
    <App type="server" url={req.url} context={context} />,
  );

  if (context.url) {
    return res.redirect(302, context.url);
  }

  const helmet = Helmet.renderStatic();

  const LoadingBarStyle = !isMobileBrowser() ? getPaceLoadingBarStyle() : '';
  const LoadingBarScript = !isMobileBrowser() ? getPaceLoadingBarScript() : '';

  const SplitPointsScript = `
    <script>
      window.splitPoints = ${JSON.stringify(context.splitPoints)};
      window.serverSideHeaders = ${JSON.stringify(req.headers)};
    </script>
  `;
  const ChunkManifestScript = manifests.client
    ? `
    <script src="${manifests.client['manifest.js']}"></script>
  `
    : '';

  return res.send(`
    <!doctype html>
    <html>
      <head>
        ${helmet.title.toString()}
        ${helmet.meta.toString()}
        ${helmet.link.toString()}
        ${helmet.script.toString()}
        ${helmet.noscript.toString()}

        <link rel="stylesheet" href="${
          !manifests.server
            ? '/dist/server/main.css'
            : manifests.server['main.css']
        }" />
        ${LoadingBarStyle}
      </head>
      <body>
        <div id="content">${markup}</div>

        ${LoadingBarScript}
        ${SplitPointsScript}
        ${ChunkManifestScript}
        <script src="${
          !manifests.client
            ? '/dist/client/vendors.js'
            : manifests.client['vendors.js']
        }"></script>
        <script src="${
          !manifests.client
            ? '/dist/client/main.js'
            : manifests.client['main.js']
        }"></script>
      </body>
    </html>
  `);
};

server.js

The server entry will generate the markup on the server and send it to the client. A few things to note:

  • if a redirection happens during the rendering of the app, it will immediately redirect the client, and they will only receive one markup
  • we are using react-helmet to generate dynamic head tag
  • we are injecting the splitPoints and serverSideHeaders into the window, so the client can use them
  • we are importing the CSS file and JS chunks differently whether we are in development mode or production mode, but we will cover this part later
  • we are using pace.js to show the user the site isn't responsive yet, but this is a matter of preference and totally optional
import * as Bundles from './components/Bundles';
import App from './App';

const doRender = () => {
  render(
    <AppContainer>
      <App type="client" />
    </AppContainer>,
    document.getElementById('content'),
  );
};

const serverSideHeaders = window.serverSideHeaders || {};
initializeServerSideHeaders(serverSideHeaders);

const splitPoints = window.splitPoints || [];
Promise.all(splitPoints.map(chunk => Bundles[chunk].loadComponent())).then(
  doRender,
);

client.js

The client entry will receive the splitPoints (which are the chunks needed for the request), load them, and wait for them to be ready to render. It will also receive the server side headers because it is often useful to have access to headers we otherwise couldn't access from the client (e.g. Accept-Language or custom headers).

const App = ({ type, url, context }) => {
  const Routing =
    type === 'client' ? (
      <ClientRouting />
    ) : (
      <ServerRouting url={url} context={context} />
    );

  return (
    <div>
      <Head />
      {Routing}
    </div>
  );
};

App.js

This component will render the Head (containing meta tags), and the right router (browser or static) based on the type of the App.

const Head = () => (
  <Helmet>
    <title>{'Server Side Rendering Starter Pack'}</title>

    <meta charSet="utf-8" />
    <meta httpEquiv="X-UA-Compatible" content="IE=edge" />

    <meta name="title" content="SSR Starter Pack" />
    <meta name="author" content="Adrien HARNAY" />
    <meta name="application-name" content="SSR Starter Pack" />
    <meta
      name="description"
      content="A starter pack to help you implement your own solution for SSR"
    />
    <meta
      name="keywords"
      content="react, ssr, server, side, rendering, webpack"
    />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
    />
  </Helmet>
);

Head.js

Here we have meta tags which can be overridden anywhere in the app.

import { renderRoutes } from 'react-router-config';

import routes from './routes';

const ServerRouting = ({ url, context }) => (
  <StaticRouter location={url} context={context}>
    {renderRoutes(routes)}
  </StaticRouter>
);

ServerRouting.js

import { renderRoutes } from 'react-router-config';

import routes from './routes';

const ClientRouting = () => (
  <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
);

ClientRouting.js

And finally, the last pieces of the puzzle! Nothing fancy here, we are following the React-Router docs and using the appropriate router for each side.

CSS Modules working without FOUT

We got JS covered, but what about CSS? If you ever tried using style-loader with SSR, you will know it doesn't work on the server. People using CSS in JS are laughing in the back of the room. Well, we're using CSS Modules and we're not giving up this easy!

The solution here is rather simple. We will use extract-text-webpack-plugin on the server to bundle our CSS in a separate file, which will be requested by the HTML we send to our users. While we're at it, we should use autoprefixer with a .browserslistrc to make sure our CSS works on every browser we wish to support!

const ExtractTextPlugin = require('extract-text-webpack-plugin');

const extractCSS = new ExtractTextPlugin({
  filename: !IS_PRODUCTION
    ? 'server/[name].css'
    : 'server/[name].[contenthash:8].css',
  ignoreOrder: true,
});

const getCommonCSSLoaders = () => [
  {
    loader: 'css-loader',
    options: {
      modules: true,
      importLoaders: 1,
      localIdentName: !IS_PRODUCTION
        ? '[name]_[local]_[hash:base64:3]'
        : '[local]_[hash:base64:3]',
      minimize: stripUselessLoaderOptions(IS_PRODUCTION),
    },
  },
  {
    loader: 'postcss-loader',
    options: {
      sourceMap: stripUselessLoaderOptions(!IS_PRODUCTION),
      ident: 'postcss',
      plugins: () => [
        require('postcss-flexbugs-fixes'),
        autoprefixer({
          env: NODE_ENV,
          flexbox: 'no-2009',
        }),
      ],
    },
  },
];

const rules = [
  {
    test: /\.css$/,
    loader: extractCSS.extract({
      fallback: 'style-loader',
      use: [...getCommonCSSLoaders()],
    }),
  },
  {
    test: /\.scss$/,
    loader: extractCSS.extract({
      fallback: 'style-loader',
      use: [
        ...getCommonCSSLoaders(),
        ...(!IS_PRODUCTION
          ? [
              {
                loader: 'resolve-url-loader',
              },
            ]
          : []),
        {
          loader: 'sass-loader',
          options: !IS_PRODUCTION
            ? {
                sourceMap: true,
              }
            : undefined,
        },
      ],
    }),
  },
];

const plugins = [extractCSS];

webpack.config.server.js

const ExtractTextPlugin = require('extract-text-webpack-plugin');

const extractCSS = new ExtractTextPlugin({
  filename: !IS_PRODUCTION
    ? 'server/[name].css'
    : 'server/[name].[contenthash:8].css',
  ignoreOrder: true,
});

const getCommonCSSLoaders = () => [
  {
    loader: 'style-loader',
  },
  {
    loader: 'css-loader',
    options: {
      modules: true,
      importLoaders: 1,
      localIdentName: !IS_PRODUCTION
        ? '[name]_[local]_[hash:base64:3]'
        : '[local]_[hash:base64:3]',
      minimize: stripUselessLoaderOptions(IS_PRODUCTION),
    },
  },
  {
    loader: 'postcss-loader',
    options: {
      sourceMap: stripUselessLoaderOptions(!IS_PRODUCTION),
      ident: 'postcss',
      plugins: () => [
        require('postcss-flexbugs-fixes'),
        autoprefixer({
          env: NODE_ENV,
          flexbox: 'no-2009',
        }),
      ],
    },
  },
];

const rules = [
  {
    test: /\.css$/,
    use: [...getCommonCSSLoaders()],
  },
  {
    test: /\.scss$/,
    use: [
      ...getCommonCSSLoaders(),
      ...(!IS_PRODUCTION
        ? [
            {
              loader: 'resolve-url-loader',
            },
          ]
        : []),
      {
        loader: 'sass-loader',
        options: !IS_PRODUCTION
          ? {
              sourceMap: true,
            }
          : undefined,
      },
    ],
  },
];

webpack.config.client.js

And if you remember, in the markup the client will receive:

<link
  rel="stylesheet"
  href="${!manifests.server ? '/dist/server/main.css' : manifests.server['main.css']}"
/>

server.js

CSS is covered too, and easily! We haven't noticed any Flash of Unstyled Content with this approach (testing with throttled connection), so I think you are good to continue reading!

Note: my initial idea was to use something like purifycss to strip any CSS not used in the HTML we would send to the user, and inline the result in the <head />. Unfortunately, after several tests I couldn't manage to make it run in under 4 seconds for fairly small pages.

Smaller images inlined with JS, larger images served by S3 (or some other CDN)

The goal here is to inline small images so they can load instantly, without adding too much weight to our JS bundles. First, we will define a breakpoint between small and large images. Let's say 20kb.

Generating images

const rules = [
  {
    test: /.*\.(eot|woff|woff2|ttf|svg|png|jpe?g|gif)$/i,
    use: [
      {
        loader: 'url-loader',
        options: {
          name: 'images/[name].[hash].[ext]',
          limit: 20000,
        },
      },
      {
        loader: 'image-webpack-loader',
        options: {
          bypassOnDebug: true,
          mozjpeg: {
            quality: 85,
          },
          pngquant: {
            quality: '80-90',
            speed: 1,
          },
        },
      },
    ],
  },
];

webpack.config.client.js

For this rule, use the same config on the server and on the client, except for emitFile: false on the server

Thanks to url-loader, images which weigh less than 20kb after compression will be inlined, while larger images will be loaded by the browser.

Did you say compression?

Yes! While we're at it, we will use image-webpack-loader which provides a way to compress images at build time, so we can ensure our users only download the most optimized content.

And voila! Images are generated by the client build, and ignored by the server build (because the output would be the same).

Accessing them from a CDN

Now, how do we access our images from a CDN?

For the storing part, just put your images on a S3 bucket or some other CDN.

const PUBLIC_PATH =
  !IS_PRODUCTION || IS_LOCAL ? '/dist/' : process.env.ASSETS_URL;

webpack.config.client.js

As for accessing them, provide ASSETS_URL to the build script in package.json, and it will replace dist with the proper URL! Your images will be loaded from dist in development, and from your CDN in production.

Tip: you can always use the build:local script to debug your app in a production environment, being able to access your assets from `dist

Long-term caching of assets, including chunks (production only)

One step away from production! But what about cache? What is the point of providing a blazing-fast website when the user has to download every asset every time he visits it?

If you're not familiar with the notion of long-term caching, I suggest you read the docs from Webpack. Basically, it allows for your assets to be cached indefinitely, unless their content changes. Note that this only affects production build, as you don't want any cache in development.

Bundling node modules in a vendors chunk

Node modules are heavy, and change less often than your code. It would be a shame if the client would have to download node modules all over again each time a new feature is deployed! It would, but isn't, because we will bundle our node modules in a separate chunk, which will only be invalidated when dependencies get updated.

Also, code which is common to multiple chunks could be exported to a separate chunk so it only gets downloaded once (and when it changes, of course).

const plugins = [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'client',
    async: 'common',
    children: true,
    minChunks: (module, count) => {
      if (module.resource && /^.*\.(css|scss)$/.test(module.resource)) {
        return false;
      }
      return (
        count >= 3 && module.context && !module.context.includes('node_modules')
      );
    },
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'client',
    children: true,
    minChunks: module =>
      module.context && module.context.includes('node_modules'),
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendors',
    minChunks: module =>
      module.context && module.context.includes('node_modules'),
  }),
];

webpack.config.client.js

Now, modules imported in 3 chunks or more will go in the common chunk, and node modules will go into the vendors chunk.

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

externals: [nodeExternals()],

webpack.config.server.js

And on the server, we don't even bundle node modules, as they are accessible from the node_modules folder.

Generating hashes in our assets names

For every asset, we will want to have a hash based on its content, so that if even one byte changed, the hash would too. We will achieve this by specifying it in our assets names.

const rules = {
  {
    loader: 'url-loader',
    options: {
      name: 'images/[name].[hash].[ext]',
    },
  },
};
...
output: {
  filename: !IS_PRODUCTION ? 'client/[name].js' : 'client/[name].[chunkhash].js',
  chunkFilename: !IS_PRODUCTION ? 'client/chunks/[name].chunk.js' : 'client/chunks/[name].[chunkhash].chunk.js',
},

webpack.config.client.js

const extractCSS = new ExtractTextPlugin({
  filename: !IS_PRODUCTION ? 'server/[name].css' : 'server/[name].[contenthash:8].css',
});
...
const rules = {
  {
    loader: 'url-loader',
    options: {
      name: 'images/[name].[hash].[ext]',
    },
  },
};

webpack.config.server.js

Also, we will use md5-hash-webpack-plugin for more consistent hashes.

const Md5HashPlugin = require('md5-hash-webpack-plugin');

const prodPlugins = [new Md5HashPlugin()];

webpack.config.client.js

Mapping hashed names to predictable names

How will we include our assets in the document if their name is dynamic? Well, we will have to generate files which will map predictable chunk names to dynamic ones. In order to do this, we will use webpack-manifest-plugin.

const ManifestPlugin = require('webpack-manifest-plugin');

const prodPlugins = [
  new ManifestPlugin({
    fileName: 'client-manifest.json',
    publicPath: PUBLIC_PATH,
  }),
];

webpack.config.client.js

const ManifestPlugin = require('webpack-manifest-plugin');

const prodPlugins = [
  new ManifestPlugin({
    fileName: 'server-manifest.json',
    publicPath: PUBLIC_PATH,
  }),
];

webpack.config.server.js

This code will output two files containing our maps.

Including assets in the document

The last step is to include our assets in the document.

const manifests = {};
manifests.server = require('./public/dist/server-manifest');
manifests.client = require('./public/dist/client-manifest');

app.use(serverRender(manifests));

app.js

Note: in development, serverRender will also get called (by Webpack-dev-server) with an object as a parameter

const render = manifests => (req, res) => {
  const markup = renderToString(
    <App type="server" url={req.url} context={context} />,
  );

  return res.send(`
    <!doctype html>
    <html>
      <head>
        <link rel="stylesheet" href="${
          !manifests.server
            ? '/dist/server/main.css'
            : manifests.server['main.css']
        }" />
      </head>
      <body>
        <div id="content">${markup}</div>

        <script src="${
          !manifests.client
            ? '/dist/client/vendors.js'
            : manifests.client['vendors.js']
        }"></script>
        <script src="${
          !manifests.client
            ? '/dist/client/main.js'
            : manifests.client['main.js']
        }"></script>
      </body>
    </html>
  `);
};

server.js

And this is it! Your user will download your content once, and keep it in cache until it changes.

A proper development environment

I talked a lot about the production setup, but what about development? It is quite similar to production, except we add hot reloading to the server and client, meaning we don't have to rebuild between files changes.

const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const webpackHotServerMiddleware = require('webpack-hot-server-middleware');
const clientConfig = require('./webpack.config.client');
const serverConfig = require('./webpack.config.server');

const multiCompiler = webpack([clientConfig, serverConfig]);
const clientCompiler = multiCompiler.compilers[0];

app.use(
  webpackDevMiddleware(multiCompiler, {
    publicPath: clientConfig.output.publicPath,
    noInfo: true,
    stats: { children: false },
  }),
);
app.use(webpackHotMiddleware(clientCompiler));
app.use(
  webpackHotServerMiddleware(multiCompiler, {
    serverRendererOptions: { outputPath: clientConfig.output.path },
  }),
);

app.dev.js

We will get rid of sourcemaps and hashes for faster builds, because we will have to build twice (once for the server, and once for the client).

A painless experience for the developer

Last but not least: the developer experience. Let's quickly recap the steps to integrate SSR into an existing codebase, assuming you're already bundling your code with Webpack and using React-Router :

When I said create, you obviously read borrow from this article

And once it is set up, the steps to create a new route:

  • add the route in the AsyncBundles and Bundles files
  • also add it in the routes file

Aaaaaand that's it! You are all set up and ready to go to production. We have found this setup to be quite effective, and have been using it with Amazon Elastic Beanstalk, with a proper load-balancing configuration for a few months now.

Note on performance: in React 15, the render function is synchronous on the server, meaning that it could be a performance bottleneck if you have a lot of simultaneous requests. Fortunately, an async render is coming with React 16! You can already try it by installing react@next.

That's pretty much all I have to share on the subject! Feel free to share your thoughts, to point out any mistakes I would have made and to submit improvements to the ssr-starter-pack! PRs are more than welcome. Also, feel free to come discuss on the #ssr channel of Reactiflux!