Server Side Rendering with React and Redux
Getting started
As this guide focused exceptionally on ssr
, i offering you to clone my github react project https://github.com/mirchenko/react-ssr-boilerplate. This is react boilerplate only, without server side rendering.
Once downloading complete run install command yarn
or npm install
After that, run yarn start
command and navigate to localhost:8080
If you see something like this, everything is ok and you installed project on the right way. I leave link to countries api https://restcountries.eu/rest/v2
, so you can feel free to play with.
The Problem
Move to the console and see React warning message.
Then switch to the Network
tab and choose localhost
on the left bar.
The problem is that content generated locally by react does not match to content returned by server. So necessary to write server which will return original content.
Server
For correct handling ES6 features on servers bundle set up webpack server configuration.
Install externals plugin for webpack and express server library.
yarn add webpack-node-externals express
Create webpack.server.config.js in root of the project and put some code in there.
const path = require('path');
const webpackNodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'development',
target: 'node',
entry: './server/index.js',
output: {
path: path.resolve(__dirname),
filename: 'server.js'
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
}
]
},
externals: [webpackNodeExternals()]
};
Put server bundling script to the package.json file.
"scripts": {
"start": "webpack-dev-server",
"build": "webpack --config webpack.build.config.js",
"server-build": "webpack --config webpack.server.config.js"
},
Create server
directory in the root of the project, then create index.js
file in there. Set up your server in server/index.js
file.
import express from 'express';
const PORT = 8079;
const app = express();
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
Run react bundle build command at first:
yarn build
Then run server bundling command:
yarn server-build
Once this command complete you should see server.js
file in the root of your project. Run server by node js.
node server.js
Look at the terminal, you should see console message from your server.
Frontend service listening on port: 8079
Time to add route handler to server/index.js
file.
import express from 'express';
const PORT = 8079;
const app = express();
app.get('*', (req, res) => {
const raw =`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="dist/bundle.js"></script>
</body>
</html>
`;
res.send(raw);
});
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
Rebuild server and start again, switch to the browser and you will see raw content returning by the server.
But there is no fetched countries there, because when browser calls scripts /dist/bundle.js
server does not understand thats, and on each route server returning content. Fix that using express.static
import express from 'express';
const PORT = 8079;
const app = express();
app.use('/dist', express.static('dist'));
app.use('/img', express.static('img'));
app.get('*', (req, res) => {
const raw =`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="dist/bundle.js"></script>
</body>
</html>
`;
res.send(raw);
});
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
Switch to the browser and see countries.
But in server response #app
container still empty, and warning massage on console still present.
As we can not use BrowserRouter
on server side, we will use StaticRouter
. Also we have same set up as frontend, but wrap it all by renderToString
function from react-dom/server
library.
import React from 'react';
import express from 'express';
import { Provider } from 'react-redux';
import { StaticRouter } from 'react-router-dom';
import { renderToString } from 'react-dom/server';
import store from '../src/store';
import Routes from '../src/router/Routes';
const PORT = 8079;
const app = express();
app.use('/dist', express.static('dist'));
app.use('/img', express.static('img'));
app.get('*', (req, res) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter>
<Routes />
</StaticRouter>
</Provider>
);
const raw =`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">${content}</div>
<script src="dist/bundle.js"></script>
</body>
</html>
`;
res.send(raw);
});
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
To make ease working with raw render features, create server/render.js
file and past all render things to it.
server/render.js
file should looks like this:
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import { StaticRouter } from 'react-router-dom';
import Routes from '../src/router/Routes';
export default store => {
const content = renderToString(
<Provider store={store}>
<StaticRouter>
<Routes />
</StaticRouter>
</Provider>
);
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">${content}</div>
<script src="dist/bundle.js"></script>
</body>
</html>
`;
};
server/index.js
file should looks like this:
import express from 'express';
import render from './render';
import store from '../src/store';
const PORT = 8079;
const app = express();
app.use('/dist', express.static('dist'));
app.use('/img', express.static('img'));
app.get('*', (req, res) => {
const content = render(store);
res.send(content);
});
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
Add store object to raw content and stringify it before.
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import { StaticRouter } from 'react-router-dom';
import Routes from '../src/router/Routes';
export default store => {
const content = renderToString(
<Provider store={store}>
<StaticRouter>
<Routes />
</StaticRouter>
</Provider>
);
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">${content}</div>
<script>
window.INITIAL_STATE = ${JSON.stringify(store.getState())}
</script>
<script src="dist/bundle.js"></script>
</body>
</html>
`;
};
Build and run server again, navigate to browser, look at localhost
on Network
tab and see store object and rendered content returned from server.
Error in console gone missing. you can see html content, but .countries-container
is empty. To fix that necessary to handle fetching inside components on server. But before handling fetching need to completely rewrite routes.
Install react-router-config
library at first:
yarn add react-router-config
Once installation finished move to /src/router/Routes.js file and set up routes schema.
import Countries from "../components/Countries";
import Country from "../components/Country";
export default [
{
component: Countries,
path: '/',
exact: true
},
{
component: Country,
path: '/:name'
}
];
Next go to the /src/router/index.js
file and modify it to server routes from router schema:
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import Routes from './Routes';
export default () => {
return (
<BrowserRouter>
<div>{renderRoutes(Routes)}</div>
</BrowserRouter>
);
};
You can stop server and run frontend by command yarn start
to make sure that new router definition works fine.
Move to to server/render.js file and do same thing with router such as frontend.
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import Routes from '../src/router/Routes';
export default (pathname, store, context) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={pathname} context={context}>
<div>{renderRoutes(Routes)}</div>
</StaticRouter>
</Provider>
);
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">${content}</div>
<script>
window.INITIAL_STATE = ${JSON.stringify(store.getState())}
</script>
<script src="dist/bundle.js"></script>
</body>
</html>
`;
};
Rebuild and run server to make sure there is no errors. Than look at your terminal and see context warning:
To fix that and finish work with server/render.js
file pass context
object and pathname
to it StaticRouter
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import Routes from '../src/router/Routes';
export default (pathname, store, context) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={pathname} context={context}>
<div>{renderRoutes(Routes)}</div>
</StaticRouter>
</Provider>
);
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="app">${content}</div>
<script>
window.INITIAL_STATE = ${JSON.stringify(store.getState())}
</script>
<script src="dist/bundle.js"></script>
</body>
</html>
`;
};
Do not forget pass it from server/index.js
import express from 'express';
import render from './render';
import store from '../src/store';
const PORT = 8079;
const app = express();
app.use('/dist', express.static('dist'));
app.use('/img', express.static('img'));
app.get('*', (req, res) => {
const context = {};
const content = render(req.path, store, context);
res.send(content);
});
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
Rebuild and rerun server, context warning message should be disappear.
Fetching
Move to src/components/Countries/index.js
file, look at Countries
component. To make easy fetching from on server add static fetching function to it. Server will only get static fetching method from this class, handle it and attach result of this method to store. After that server will pass full of data store object to the client.
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { fetchCountries } from "../../action/countries";
import { Loading } from '../../common';
import CountriesItem from './CountriesItem';
const m = ({ countries }) => ({ countries });
@connect(m, { fetchCountries })
export default class Countries extends Component {
static fetching ({ dispatch }) {
return [dispatch(fetchCountries())];
}
componentDidMount() {
this.props.fetchCountries();
}
render() {
const { countries: { isFetching, data } } = this.props;
if(isFetching) {
return <Loading />
}
return(
<div className="container">
<div className="countries-container">
{data.map((item, i) => <CountriesItem key={i} {...item} />)}
</div>
</div>
);
}
};
fething
method returns array because initial actions can be more then 1. Each of element can returning promise, so server should handle all the promises
from all components returned from StackRouter
.
Move to server/index.js
file and handle all the promises.
import express from 'express';
import { renderToString } from 'react-dom/server';
import { matchRoutes } from 'react-router-config';
import render from './render';
import store from '../src/store';
import Routes from '../src/router/Routes';
const PORT = process.env.PORT || 8079;
const app = express();
app.use('/dist', express.static('dist'));
app.use('/img', express.static('img'));
app.get('*', async (req, res) => {
const actions = matchRoutes(Routes, req.path)
.map(({ route }) => route.component.fetching ? route.component.fetching(store) : null)
.map(async actions => await Promise.all(
(actions || []).map(p => p && new Promise(resolve => p.then(resolve).catch(resolve)))
)
);
await Promise.all(actions);
const context = {};
const content = render(req.path, store, context);
res.send(content);
});
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
After all promises will handled, server will return full of data store, and full of content html raw. Rebuild and rerun server, switch to the browser. Open Network
tab, click on localhost
on the left bar.
Ssr works fine. But what about single country? If you navigate to http://localhost:8079/United%20States%20of%20America
server returns only html mok for this country, but not the data.
To fix that move to src/components/Country/index.js
, look at componentDidMount
hook, you will see this.props.fetchCountry(this.props.match.params.name);
. Necessary to pass country name somehow. Remember that, but now just write static fetching
method in this class.
static fetching ({ dispatch, path }) {
return [dispatch(fetchCountry(path.substr(1)))];
}
That pass pathname to fetching method move to server/index.js and destruct store
object and pass req.path
to it.
import express from 'express';
import { renderToString } from 'react-dom/server';
import { matchRoutes } from 'react-router-config';
import render from './render';
import store from '../src/store';
import Routes from '../src/router/Routes';
const PORT = process.env.PORT || 8079;
const app = express();
app.use('/dist', express.static('dist'));
app.use('/img', express.static('img'));
app.get('*', async (req, res) => {
const actions = matchRoutes(Routes, req.path)
.map(({ route }) => route.component.fetching ? route.component.fetching({...store, path: req.path }) : null)
.map(async actions => await Promise.all(
(actions || []).map(p => p && new Promise(resolve => p.then(resolve).catch(resolve)))
)
);
await Promise.all(actions);
const context = {};
const content = render(req.path, store, context);
res.send(content);
});
app.listen(PORT, () => console.log(`Frontend service listening on port: ${PORT}`));
Rebuild and rerun server and you will se raw html with data, and country data in store returned from server.
Done
This is easiest way setting up server side rendering with react and redux. I leave link to finished sample https://github.com/mirchenko/react-ssr
If yo have any questions or propositions feel free to ask responses.
Thank for reading.