If we look on the web today we notice that multiple sites make priority on Interactivity rather than Performance. While they look interesting and provide unusual user experience they have one common problem - they make people wait. Most of these apps weigh around 5-10mb and this gives us ~10 seconds load time with good internet connection. They sent small amount of HTML code and a big bundle of JavaScript. The sites I am speaking about are SPAs or Single Page Applications.
This type of content delivery gives us another problem - SEO. While Google executes client side code well-enough, other crawlers (like Yahoo, Bing and Baidu) see these sites as a blank pages. So the question is - how can we efficiently serve JavaScript application providing fast content delivery and solve the SEO problem. The answer is - Server Side Rendering or how we will call it - SSR.
TL;DR — Just show me the results
What will we build?
There are many other tutorials showing how to implement SSR with React.js but they all have pretty simple examples and in most cases only for development purposes. Our goal is to show a real application that can be used in production.
What will we use?
- React.js
- React Router v4
- React Helmet
- CSS Modules
- Webpack 2
- Babel
- Express.js
- PM2
Requirements
The single requirement is to have Node.js (preferable version 6) installed on your machine. Otherwise you can download it from https://nodejs.org/en/.
Project setup
1. Let’s create a new folder somewhere on the disk and call it “react-app”:
mkdir react-app
2. navigate to it:
cd react-app
3. and launch:
npm init
This will ask you a bunch of questions, and then write package.json for you allowing us to install all the needed dependencies.
4. And now we need to install our dependencies. For now we will require only React.js and ReactDOM:
npm install --save react react-dom
5. We'll also require Webpack as our module bundler with its Babel loader extension:
npm install --save-dev babel-loader babel-core babel-preset-env webpack
Configure Babel
In order to have Babel working properly with our React.js syntax we will need to install a few presets:
npm install --save-dev babel-preset-es2015 babel-preset-react babel-preset-stage-0
Then create .babelrc file with the following contents:
{
"presets": [
"es2015",
"react",
"stage-0"
]
}
Where:
- es2015 compiles ES2015 to ES5.
- react transforms JSX syntax into createElement calls.
- stage-0 allows us to use ES7 features like decorators and async / await.
Configure Webpack
We'll have two Webpack configs: one for development and one for production. These configs will also be targeted separately for client and server.
First of all, let’s create development config webpack.development.config.js with following content:
const path = require('path');
module.exports = [
{
name: 'client',
target: 'web',
entry: './client.jsx',
output: {
path: path.join(__dirname, 'static'),
filename: 'client.js',
publicPath: '/static/',
},
resolve: {
extensions: ['.js', '.jsx']
},
devtool: 'source-map',
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules\/)/,
use: [
{
loader: 'babel-loader',
}
]
}
],
},
},
{
name: 'server',
target: 'node',
entry: './server.jsx',
output: {
path: path.join(__dirname, 'static'),
filename: 'server.js',
libraryTarget: 'commonjs2',
publicPath: '/static/',
},
devtool: 'source-map',
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules\/)/,
use: [
{
loader: 'babel-loader',
}
]
}
],
},
}
];
With this config we tell Webpack to look into client.jsx and server.jsx files (that we have yet to create), resolve all files with .js and .jsx extensions, apply babel-loader to them and output final bundles into static directory that will be also our public path.
The App
Our app will have multiple pages, so we'll require a react-router component responsible for handling routing. React Router v4 is a full rewrite of popular package, which exposes its API into 3 packages: react-router, react-router-dom, and react-router-native. While the first package provides core routing functionality other two provide environment specific (browser and react-native) components.
Since we create a web application we need to install react-router-dom:
npm install --save react-router-dom
As soon as it’s intalled, create a file named client.jsx:
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
ReactDOM.render((
<BrowserRouter>
<App />
</BrowserRouter>
), document.getElementById('root'));
Since we will have server handling dynamic requests we'll be using <BrowserRouter> as our client router component.
server.jsx:
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Template from './template';
import App from './App';
export default function serverRenderer({ clientStats, serverStats }) {
return (req, res, next) => {
const context = {};
const markup = ReactDOMServer.renderToString(
<StaticRouter location={ req.url } context={ context }>
<App />
</StaticRouter>
);
res.status(200).send(Template({
markup: markup,
}));
};
}
Rendering on the server is a bit different because it's all stateless. Hence we'll use <StaticRouter> instead of a <BrowserRouter> and pass the requested url to it within the context object.
template.jsx
export default ({ markup }) => {
return `<!doctype html>
<html>
<head>
</head>
<body>
<div id="root">${markup}</div>
<script src="/static/client.js" async></script>
</body>
</html>`;
};
This will be our main layout file. Soon we'll populate it with head/meta data from React Helmet.
and App.jsx:
import React, { Component } from 'react';
export default class App extends Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
<h1>Hello World!</h1>
</div>
);
}
}
For now let’s keep it very simple, we'll get back to it very soon :)
Development Server
Our development server will be an express.js application. To have it running we need webpack-dev-middleware initialised with our development config together with webpack-hot-middleware and webpack-hot-server-middleware. These two middlewares implement hot update of the bundles both on client and server.
First of all, let’s install required dependencies:
npm i --save-dev express webpack-dev-middleware webpack-hot-middleware webpack-hot-server-middleware
Then create development.js file with the following content:
const express = require('express');
const path = require('path');
const app = express();
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const webpackHotServerMiddleware = require('webpack-hot-server-middleware');
const config = require('./webpack.development.config.js');
const compiler = webpack(config);
app.use(webpackDevMiddleware(compiler, {
publicPath: "/static/",
}));
app.use(webpackHotMiddleware(compiler.compilers.find(compiler => compiler.name === 'client')));
app.use(webpackHotServerMiddleware(compiler));
app.listen(3000);
and let’s test it by typing node development.js in our console.
And now if you open http://localhost:3000/ you should see “Hello World” message that is compiled on the server! :)
Configure Routing
Now, when we have our app serving from the server we can setup our route config. To do this, open App.jsx file and fill it with following content:
import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
class Homepage extends Component {
render() {
return (
<div>
<h1>Homepage</h1>
</div>
);
}
}
class About extends Component {
render() {
return (
<div>
<h1>About</h1>
</div>
);
}
}
class Contact extends Component {
render() {
return (
<div>
<h1>Contact</h1>
</div>
);
}
}
export default class App extends Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
<Switch>
<Route exact path='/' component={ Homepage } />
<Route path="/about" component={ About } />
<Route path="/contact" component={ Contact } />
</Switch>
</div>
);
}
}
We have created three components to serve Homepage, About and Contact pages. Let's also create a menu component to make the navigation between pages easier:
import { Link } from 'react-router-dom';
class Menu extends Component {
render() {
return (
<div>
<ul>
<li>
<Link to={'/'}>Homepage</Link>
</li>
<li>
<Link to={'/about'}>About</Link>
</li>
<li>
<Link to={'/contact'}>Contact</Link>
</li>
</ul>
</div>
);
}
}
and insert <Menu /> component on each page like this:
class Homepage extends Component {
render() {
return (
<div>
<Menu />
<h1>Homepage</h1>
</div>
);
}
}
Now when you refresh the page you should see menu added at the top and if you navigate to different pages it will serve corresponding content. The good thing about React is that due to virtual DOM it only updates the real DOM only when it has to.
React Helmet
React Helmet is a library that allows managing document meta from your React components easily. It works on the client side as well as on the server. We'll need to make a few edits to our files, but let's install it first:
npm install --save react-helmet
When it's done open template.jsx file and edit it to look like this:
export default ({ markup, helmet }) => {
return `<!doctype html>
<html ${helmet.htmlAttributes.toString()}>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
</head>
<body ${helmet.bodyAttributes.toString()}>
<div id="root">${markup}</div>
<script src="/static/client.js" async></script>
</body>
</html>`;
};
We also need to edit our server.jsx file and call Helmet.renderStatic() after ReactDOMServer.renderToString to be able to use the head data in our prerender.
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import {Helmet} from "react-helmet";
import Template from './template';
import App from './App';
export default function serverRenderer({ clientStats, serverStats }) {
return (req, res, next) => {
const context = {};
const markup = ReactDOMServer.renderToString(
<StaticRouter location={ req.url } context={ context }>
<App />
</StaticRouter>
);
const helmet = Helmet.renderStatic();
res.status(200).send(Template({
markup: markup,
helmet: helmet,
}));
};
}
Now when you rerun node development.js
you will see that the head is populated with an empty title. We'll have to modify our pages and insert there titles, however let's add some global configuration first:
App.jsx
export default class App extends Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
<Helmet
htmlAttributes={{lang: "en", amp: undefined}} // amp takes no value
titleTemplate="%s | React App"
titleAttributes={{itemprop: "name", lang: "en"}}
meta={[
{name: "description", content: "Server side rendering example"},
{name: "viewport", content: "width=device-width, initial-scale=1"},
]}
/>
<Switch>
<Route exact path='/' component={ Homepage } />
<Route path="/about" component={ About } />
<Route path="/contact" component={ Contact } />
</Switch>
</div>
);
}
}
And now we can add titles to each page, like this:
import Helmet from "react-helmet";
class Homepage extends Component {
render() {
return (
<div>
<Helmet
title="Welcome to our Homepage"
/>
<Menu />
<h1>Homepage</h1>
</div>
);
}
}
Now you should see <title /> populated correctly!
CSS Modules
Css modules allow you to write styles that are scoped locally by default. The classnames are dynamically generated, unique, and mapped to a particular component. Although this is an optional feature, it allows to prevent conflicts and reduce the css bundle size.
Here is how it looks like:
import styles from './index.scss';
export default class Homepage extends Component {
render() {
return (
<div className={ styles.container }>
<h1>Homepage</h1>
</div>
);
}
}
That will generate for us a classname that will look like: index__component___1HAA-
.
Depending on environment localIdentName can be tweaked, so in production you can set it to generate only a 10 chars length hash.
Installation
First of all we will need to install a few loaders, so we are able to make scss imports inside our components:
npm install --save-dev style-loader css-loader sass-loader node-sass
For server config we'll need a slightly different loader - isomorphic-style-loader. It works the same as style-loader, however it's optimized for server usage:
npm install --save-dev isomorphic-style-loader
Now we'll need to add them in our development config. We'll have two separate loader configs.
One for the client:
{
test: /\.scss$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
options: {
modules: true,
importLoaders: 1,
localIdentName: '[name]__[local]___[hash:base64:5]',
sourceMap: true
}
},
{
loader: 'sass-loader'
}
]
}
and one for the server:
{
test: /\.scss$/,
use: [
{
loader: 'isomorphic-style-loader',
},
{
loader: 'css-loader',
options: {
modules: true,
importLoaders: 1,
localIdentName: '[name]__[local]___[hash:base64:5]',
sourceMap: true
}
},
{
loader: 'sass-loader'
}
]
}
Now let's check if it's working.
First of all, restart our node process:
node development.js
Then create homepage.scss file:
.component {
color: blue;
}
Open our Homepage component and import the styles at the top:
import homepageStyles from './homepage.scss';
And assign them to it:
class Homepage extends Component {
render() {
return (
<div className={ homepageStyles.component }>
<Helmet
title="Welcome to our Homepage"
/>
<Menu />
<h1>Homepage</h1>
</div>
);
}
}
Now, when you visit Homepage you should see text written in blue.
Preparing for Production
Our production config will be different from development one. We have several things that we need to achieve with it:
1. JavaScript bundle effeciently minified.
2. CSS extracted to a separate file - styles.css and minified.
3. Stats file generated. We'll be building a hashed bundle which will be used for express.js app.
With that we'll need a few more plugins installed:
npm install --save-dev webpack-cleanup-plugin extract-text-webpack-plugin stats-webpack-plugin optimize-css-assets-webpack-plugin
And here is our webpack.production.config.js:
const path = require('path');
const dist = path.join(__dirname, 'dist');
const webpack = require('webpack');
const WebpackCleanupPlugin = require('webpack-cleanup-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const StatsPlugin = require('stats-webpack-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = [
{
name: 'client',
target: 'web',
entry: './client.jsx',
output: {
path: path.join(__dirname, 'static'),
filename: 'client.js',
publicPath: '/static/',
},
resolve: {
extensions: ['.js', '.jsx']
},
devtool: 'source-map',
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules\/)/,
use: [
{
loader: 'babel-loader',
}
]
},
{
test: /\.scss$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
options: {
modules: true,
importLoaders: 1,
localIdentName: '[hash:base64:10]',
sourceMap: true
}
},
{
loader: 'sass-loader'
}
]
}
],
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false,
screw_ie8: true,
drop_console: true,
drop_debugger: true
}
}),
new webpack.optimize.OccurrenceOrderPlugin(),
]
},
{
name: 'server',
target: 'node',
entry: './server.jsx',
output: {
path: path.join(__dirname, 'static'),
filename: 'server.js',
libraryTarget: 'commonjs2',
publicPath: '/static/',
},
devtool: 'source-map',
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules\/)/,
use: [
{
loader: 'babel-loader',
}
]
},
{
test: /\.scss$/,
use: ExtractTextPlugin.extract({
fallback: "isomorphic-style-loader",
use: [
{
loader: 'css-loader',
options: {
modules: true,
importLoaders: 1,
localIdentName: '[hash:base64:10]',
sourceMap: true
}
},
{
loader: 'sass-loader'
}
]
})
}
],
},
plugins: [
new ExtractTextPlugin({
filename: 'styles.css',
allChunks: true
}),
new OptimizeCssAssetsPlugin({
cssProcessorOptions: { discardComments: { removeAll: true } }
}),
new StatsPlugin('stats.json', {
chunkModules: true,
modules: true,
chunks: true,
exclude: [/node_modules[\\\/]react/],
}),
]
}
];
and production.js file:
const express = require('express');
const path = require('path');
const app = express();
const ClientStatsPath = path.join(__dirname, './static/stats.json');
const ServerRendererPath = path.join(__dirname, './static/server.js');
const ServerRenderer = require(ServerRendererPath).default;
const Stats = require(ClientStatsPath);
app.use(ServerRenderer(Stats));
app.listen(3000);
And now we can build our app by running:
NODE_ENV=production webpack --config webpack.production.config.js --progress --profile --colors
After running it we should see bundle files generated inside "static" directory.
Now we can start the server by calling:
node production.js
Deployment
To run our code on production we will use PM2. It allows us to keep application live forever, reload without downtime and has CPU / Memory monitoring dashboard inside.
First of all, log in to your server and install pm2 globally by typing:
npm install pm2 -g
then navigate to your site directory and start the application:
NODE_ENV=production pm2 start production.js
After that you will need to configure a virtual host to proxy 3000 port.
Leave a comment