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.
- Replace native
<img />
with@brigad/ideal-image-loader
- Webpack 4
- Babel 7
- CSS splitting and critical loading
- Typescript support
- Initial iteration with Webpack 3 and Babel 6, link to blog post
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
I want to start by giving credit to Emile Cantin for his post, which helped me a lot on the subject.
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:
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.
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.
The goal is to have a file (well, two actually) with the list of every route of our app.
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.
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).
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.
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'),
];
I declared the plugin twice because I import AsyncBundles
from two different paths.
For all of this to work together, we will create two entry points.
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
andserverSideHeaders
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
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).
The App
component will render the Head (containing meta tags), and the right router (browser or static) based on the type of the App.
The Head
component contains meta tags which can be overridden anywhere in the app.
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.
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),
},
},
],
},
];
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()],
),
},
};
And in the markup, the client will receive:
.map(
chunk =>
`
<link rel="stylesheet" href="${!IS_PRODUCTION ? '/' : ''}${
manifest[chunk]
}" data-href="${!IS_PRODUCTION ? '/' : ''}${manifest[chunk]}" />
`,
)
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!
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.
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]',
},
},
],
},
];
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).
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;
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
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.
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',
},
};
Way easier!
const nodeExternals = require('webpack-node-externals');
externals: [nodeExternals()],
And on the server, we don't even bundle node modules, as they are accessible from the node_modules
folder.
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',
},
{
loader: 'css-loader/locals',
options: {
localIdentName: IS_PRODUCTION
? '[local]_[hash:base64:5]'
: '[name]_[local]-[hash:base64:5]',
},
},
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'),
}),
];
This code will output a manifest mapping chunk names to their paths.
The last step is to include our assets in the document.
const manifest = require('./public/dist/client/manifest');
app.use(serverRender(manifest));
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>
`);
};
And this is it! Your user will download your content once, and keep it in cache until it changes.
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));
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 :
- create two Webpack configs (webpack.config.client.js and webpack.config.server.js)
- create two server files (app.js and app.dev.js)
- create two entry points (client.js and server.js)
- adapt your app entry (App.js)
- list all of your routes in the three files (AsyncBundles.js, Bundles.js and routes.js)
- adapt route components which render sub-routes so they can also be rendered (MainLayout.js)
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
andBundles
files - also add it in the
routes
file