Nowadays, there are few competitors in the React-based server-side rendering market. We can divide them into the following categories:
- Drop-in dynamic solutions (Next.js, Electrode, After)
- Drop-in static solutions (Gatsby, React Static)
- Custom solutions
The main difference between first two approaches is the way the app is built and served.
A static solution makes a static HTML build (with all possible router pages), and then this build can be served by a static server such as Nginx, Apache, or any other. All HTML is pre-baked, as well as the initial state. This is very suitable for websites with incremental content updates that happen infrequently, for example, for a blog.
The dynamic solution generates HTML on the fly every time the client requests it. This means we can put in any dynamic logic, or any dynamic HTML blocks such as per-request ads and so on. But the drawback is that it requires a long-running server.
This server has to be monitored and ideally should become a cluster of servers for redundancy to make sure it's highly available.
We will make the main focus of this book dynamic solutions, as they are more flexible and more complex but also require deeper understanding.
Lets dive deeper into a custom solution using only React and React Router.
Let's install the router and special package to configure routes statically (it's impossible to generate purely dynamic routes on a server):
npm i --save react-router-dom react-router-config
Now, let's configure the routes:
// routes.js
const routes = [
{
path: '/',
exact: true,
component: Index
},
{
path: '/list',
component: List
}
];
export default routes;
The main app entry point should look like this:
// index.js
import React from 'react';
import {render} from 'react-dom';
import BrowserRouter from 'react-router-dom/BrowserRouter';
import {renderRoutes} from 'react-router-config';
import routes from './routes';
const Router = () => {
return (
<BrowserRouter>
{renderRoutes(routes)}
</BrowserRouter>
)
};
render(<Router />, document.getElementById('app'));
On the server, we will have the following:
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import StaticRouter from 'react-router-dom/StaticRouter';
import { renderRoutes } from 'react-router-config';
import routes from './src/routes';
const app = express();
app.get('*', (req, res) => {
let context = {}; // pre-fill somehow
const content = renderToString(
<StaticRouter location={req.url} context={context}>
{renderRoutes(routes)}
</StaticRouter>
);
res.render('index', {title: 'SSR', content });
});
But, this will simply render the page with no data. In order to prepopulate data into the page, we need to do the following, both in the component and in the server:
- Each data-enabled component must expose a method that the server should call during route resolution
- The server iterates over all matched components and utilizes exposed methods
- The server collects the data and puts it into storage
- The server renders the HTML using routes and data from storage
- The server sends to the client the resulting HTML, along with data
- The client initializes using the HTML and prepopulates the state using data
We intentionally won't show steps 3 and onward, because there is no generic way for pure React and React Router. For storage, most solutions will use Redux and this is a whole another topic. So, here we just show the basic principle:
// list.js
import React from "react";
const getText = async () => (await (await fetch('https://api.github.com/users/octocat')).text());
export default class List extends React.Component {
state = {text: ''};
static async getInitialProps(context) {
context.text = await getText();
}
async componentWillMount() {
const text = await getText();
this.setState({text})
}
render() {
const {staticContext} = this.props;
let {text} = this.state;
if (staticContext && !text) text = staticContext.text;
return (
<pre>Text: {text}</pre>
);
}
}
// server.js
// all from above
app.get('*', (req, res) => {
const {url} = req;
const matches = matchRoutes(routes, url);
const context = {};
const promises = matches.map(({route}) => {
const getInitialProps = route.component.getInitialProps;
return getInitialProps ? getInitialProps(context) : Promise.resolve(null)
});
return Promise.all(promises).then(() => {
console.log('Context', context);
const content = renderToString(
<StaticRouter location={url} context={context}>
{renderRoutes(routes)}
</StaticRouter>
);
res.render('index', {title: 'SSR', content});
});
});
The reason why we are not covering those aspects is because even after heavy development, it becomes obvious that the custom solution still has quirks and glitches, primarily because React Router was not meant to be used on a server, so every custom solution always has some hacks. Even the authors of React Router say that they decided not to use server-side rendering in their projects. So, it would be much better to take a stable/standard solution that was built with server-side rendering in mind from day one.
Among other competitors, Next.js stands out as one of the pioneers of this approach; this framework is currently the most popular. It offers a very convenient API, easy installation, zero configuration, and a huge community. Electrode may be more flexible and powerful than Next.js, but it has extremely complicated configuration. After this is a Next.js-alike framework, which is built on top of React Router, Helmet and other familiar libraries, but the community is still relatively small so far, although it definitely worth to mention.
A full comparison is available in my article here: https://medium.com/disdj/solutions-for-react-app-development-f9fcaeba504.