Skip to content

Brigad/ssr-starter-pack

Repository files navigation

ssr-starter-pack

This starter pack is designed to be a simple solution to start a React project with Server Side Rendering, or to help you integrate it into an existing project.

Changelog

2.1

  • Replace native <img /> with @brigad/ideal-image-loader

2.0

  • Webpack 4
  • Babel 7
  • CSS splitting and critical loading
  • Typescript support

1.0

Getting started

Development:

yarn
yarn dev

Local build:

yarn build:local
yarn start

To build for production modify package.json and run

(modify package.json to include a link to your CDN)
yarn build
yarn start

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 we want

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:

asyncComponent.js

The client version asynchronously loads a component and renders it when it is ready. It also retries every 2 seconds if the chunk fails to load.

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.

Declaring the chunks

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

AsyncBundles.js

Bundles.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.

Routing the chunks

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).

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 call renderRoutes() so that their children routes are rendered.

MainLayout.js

Using the right version on client and server

How will we get the right file to be imported? 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.

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 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 react-helmet to generate dynamic head tag
  • 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

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).

App.js

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

Head.js

The Head component contains meta tags which can be overridden anywhere in the app.

ServerRouting.js

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 FOUC

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 that easy!

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

const autoprefixer = require('autoprefixer');

const getStylesLoaders = (enableCSSModules, additionalLoaders = 0) => [
  {
    loader: 'css-loader/locals',
    options: {
      modules: enableCSSModules,
      importLoaders: 1 + additionalLoaders,
      localIdentName: IS_PRODUCTION
        ? '[local]_[hash:base64:5]'
        : '[name]_[local]-[hash:base64:5]',
    },
  },
  {
    loader: 'postcss-loader',
    options: {
      ident: 'postcss',
      plugins: () => [
        require('postcss-flexbugs-fixes'),
        autoprefixer({
          env: NODE_ENV,
          flexbox: 'no-2009',
        }),
      ],
      sourceMap: stripUselessLoaderOptions(!IS_PRODUCTION),
    },
  },
];

const rules = [
  {
    test: /\.css$/,
    include,
    exclude,
    use: getStylesLoaders(true),
  },
  {
    test: /\.css$/,
    include: exclude,
    use: getStylesLoaders(false),
  },
  {
    test: /\.scss$/,
    include,
    exclude,
    use: [
      ...getStylesLoaders(true, 1),
      {
        loader: 'sass-loader',
        options: {
          sourceMap: stripUselessLoaderOptions(!IS_PRODUCTION),
        },
      },
    ],
  },
];

webpack.config.server.js

const autoprefixer = require('autoprefixer');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');

const getStylesLoaders = (enableCSSModules, additionalLoaders = 0) => [
  {
    loader: MiniCssExtractPlugin.loader,
  },
  {
    loader: 'css-loader',
    options: {
      modules: enableCSSModules,
      importLoaders: 1 + additionalLoaders,
      localIdentName: IS_PRODUCTION
        ? '[local]_[hash:base64:5]'
        : '[name]_[local]-[hash:base64:5]',
      sourceMap: stripUselessLoaderOptions(!IS_PRODUCTION),
    },
  },
  {
    loader: 'postcss-loader',
    options: {
      ident: 'postcss',
      plugins: () => [
        require('postcss-flexbugs-fixes'),
        autoprefixer({
          env: NODE_ENV,
          flexbox: 'no-2009',
        }),
      ],
      sourceMap: stripUselessLoaderOptions(!IS_PRODUCTION),
    },
  },
];

const rules = [
  {
    test: /\.css$/,
    include,
    exclude,
    use: getStylesLoaders(true),
  },
  {
    test: /\.css$/,
    include: exclude,
    use: getStylesLoaders(false),
  },
  {
    test: /\.scss$/,
    include,
    exclude,
    use: [
      ...getStylesLoaders(true, 1),
      {
        loader: 'sass-loader',
        options: {
          sourceMap: stripUselessLoaderOptions(!IS_PRODUCTION),
        },
      },
    ],
  },
];

const plugins = [
  new MiniCssExtractPlugin({
    filename: 'client/[name].[contenthash].css',
    chunkFilename: 'client/chunks/[name].[contenthash].chunk.css',
  }),
];

module.exports = {
  optimization: {
    minimizer: stripUselessLoaderOptions(
      IS_PRODUCTION && [new OptimizeCssAssetsPlugin()],
    ),
  },
};

webpack.config.client.js

And in the markup, the client will receive:

.map(
      chunk =>
        `
    <link rel="stylesheet" href="${!IS_PRODUCTION ? '/' : ''}${
          manifest[chunk]
        }" data-href="${!IS_PRODUCTION ? '/' : ''}${manifest[chunk]}" />
  `,
    )

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!

Images served by S3 (or some other CDN)

As images are not crucial to the page and can be loaded when the client receives the markup, we will use a CDN so that they are properly cached and don't add overhead to the first downloads.

Generating images

const rules = [
  {
    test: /\.(jpe?g|png|svg|gif)$/i,
    include,
    exclude,
    use: [
      {
        loader: '@brigad/ideal-image-loader',
        options: {
          name: 'images/[name].[hash].[ext]',
          base64: IS_PRODUCTION,
          svgoCleanUpIds: IS_PRODUCTION,
          webp: IS_PRODUCTION ? undefined : false,
          warnOnMissingSrcset: !IS_PRODUCTION,
        },
      },
    ],
  },
  {
    test: /\.(jpe?g|png|svg|gif)$/i,
    include: exclude,
    use: [
      {
        loader: 'file-loader',
        options: {
          name: 'images/[name].[hash].[ext]',
        },
      },
    ],
  },
];

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 file-loader, all images will be emitted and the browser will load them.

And voilà! 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. Be sure to respect the same architecture as in your project.

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

With Webpack 3, we had to handle code splitting ourselves. But now, all we have to do is:

modules.exports = {
  mode: 'production',
  optimization: {
    runtimeChunk: 'single',
  },
};

webpack.config.client.js

Way easier!

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: 'file-loader',
    options: {
      name: 'images/[name].[hash].[ext]',
    },
  },
};
...
const plugins = [
  new MiniCssExtractPlugin({
    filename: 'client/[name].[contenthash].css',
    chunkFilename: 'client/chunks/[name].[contenthash].chunk.css',
  }),
];
...
output: {
  filename: IS_PRODUCTION
      ? 'client/[name].[contenthash].js'
      : 'client/[name].js',
    chunkFilename: IS_PRODUCTION
      ? 'client/chunks/[name].[contenthash].chunk.js'
      : 'client/chunks/[name].chunk.js',
},

webpack.config.client.js

{
  loader: 'css-loader/locals',
  options: {
    localIdentName: IS_PRODUCTION
      ? '[local]_[hash:base64:5]'
      : '[name]_[local]-[hash:base64:5]',
  },
},

webpack.config.server.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',
    filter: ({ path: filePath }) => !filePath.endsWith('.map.js'),
  }),
];

webpack.config.client.js

This code will output a manifest mapping chunk names to their paths.

Including assets in the document

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

const manifest = require('./public/dist/client/manifest');

app.use(serverRender(manifest));

app.js

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

const formatWebpackDevServerManifest = manifestObject =>
  Object.entries(manifestObject.clientStats.assetsByChunkName).reduce(
    (allManifest, [key, chunks]) => ({
      ...allManifest,
      ...(Array.isArray(chunks)
        ? chunks.reduce(
            (prev, curr) => ({
              ...prev,
              [`${key}${curr.endsWith('.css') ? '.css' : '.js'}`]: curr,
            }),
            {},
          )
        : { chunks }),
    }),
    {},
  );

const render = manifestObject => (req, res) => {
  const IS_PRODUCTION = __NODE_ENV__ === 'production';

  const manifest = IS_PRODUCTION
    ? manifestObject
    : formatWebpackDevServerManifest(manifestObject);

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

  const SplitPointsStyles = Object.keys(manifest)
    .filter(
      chunkName =>
        chunkName.endsWith('.css') &&
        (chunkName.length > 100 || chunkName.match(splitPointsRegex)),
    )
    .sort(a =>
      ENTRY_POINTS.find(entryPoint => a.split('.')[0] === entryPoint) ? -1 : 1,
    )
    .map(
      chunk =>
        `
    <link rel="stylesheet" href="${!IS_PRODUCTION ? '/' : ''}${
          manifest[chunk]
        }" data-href="${!IS_PRODUCTION ? '/' : ''}${manifest[chunk]}" />
  `,
    )
    .join('\n');

  const RuntimeScript = `
    <script src="${
      manifest && manifest['runtime.js']
        ? manifest['runtime.js']
        : '/client/runtime.js'
    }"></script>
  `;
  const EntryScripts = ENTRY_POINTS.map(
    entryPoint => `
    <script src="${!IS_PRODUCTION ? '/' : ''}${
      manifest[`${entryPoint}.js`]
    }"></script>
  `,
  );

  return res.send(`
    <!doctype html>
    <html>
      <head>
        ${SplitPointsStyles}
      </head>
      <body>
        <div id="content">${markup}</div>

        ${RuntimeScript}
        ${EntryScripts}
      </body>
    </html>
  `);
};

server.js

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

A painless experience for the developer

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 file 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,
    logLevel: 'warn',
    stats: 'minimal',
  }),
);
app.use(webpackHotMiddleware(clientCompiler));
app.use(webpackHotServerMiddleware(multiCompiler));

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).

Last but not least: how to migrate to and maintain? 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