Let's first understand what is server-side rendering and why we might need it. Most of the web applications these days are primarily written in JavaScript and hence does most of the heavy lifting on the client side. Frameworks like React, Angular and Vue, by default, render the contents on the client side as well. This is client-side rendering, where the browser gets a bundled JavaScript code, parses it then executes and renders the resulting HTML content on the screen. On the other hand, server-side rendering is where the markup or HTML is generated on the server and sent as is to the client browser for it to render the content. Basically, the difference lies in where the HTML is generated. Server-side rendering has been the de facto way for a long time before frontend frameworks came into play. They still have advantages like -
-
Lesser load times as the user doesn't have to wait for the JavaScript to get parsed and executed in order to get content on the screen.
-
Better SEO, because the crawlers can better index the page. Even though, search engine crawlers are catching up to the frontend frameworks.
Hence, it is very common for developers to opt for server-side rendering. Solutions like Next.js, Gatsby.js, Razzle, etc. help us get started with server-side rendering. But, they come with their own opinionated choices that one might disagree with or may choose to create their own solution for any given reason. Therefore, we will implement a basic Server-side rendered React app to understand the fundamental concepts about it.
Note: These article assumes the user has experience working with React and Webpack. If not, you may like go over our other articles - Introduction to React, Introduction to Webpack 4.
Getting Started
We will create a small app that will have a home page that will fetch a list of movies from an API and list them out. Each movie will have its own page that will list out further details about them. This is simply a twist on the common blog app tutorials. We will start by creating an empty directory and initiate a JavaScript project by running
$ npm init -y
Then, we will install the dependencies we will need
$ npm i react react-dom react-router-dom react-helmet express cors axios
We will be using react
react-dom
react-router-dom
react-helmet
in our react app. We will need express
to build the server that will be responsible for server rendering the app. We will need cors
to allow Cross Site Requesting from the API server and axios
for a unified way to query the API on both client and server. You can also use something like isomorphic-fetch
instead of axios
.
Next, we will get our dev dependencies
$ npm i -D webpack webpack-cli webpack-node-externals nodemon babel-core babel-loader babel-preset-env babel-preset-react babel-preset-stage-2
We will need babel-core
babel-loader
and all its presets for transpiling our code. We will need webpack
for bundling while webpack-cli
will be watching the filesystem and rebundling if anything changes which will in turn trigger nodemon
to restart our server. We will not be setting up anything fancy like hot module replacement (HMR), etc. so that we can focus solely on the bare bones necessities of server-side rendering. Lastly, webpack-node-externals
will help us exclude externals modules from our server bundle like react
because we will generate markup on the server and not run a full react app.
Setup Babel and Webpack
webpack.config.js
const path = require('path');
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');
const common = {
rules: [{ test: /\.(js)$/, use: 'babel-loader' }],
};
const clientConfig = {
entry: './src/client/index.js',
output: {
path: path.resolve(__dirname, 'public'),
filename: 'bundle.js',
publicPath: '/',
},
module: common,
plugins: [
new webpack.DefinePlugin({
__isBrowser__: 'true',
}),
],
};
const serverConfig = {
entry: './src/server/index.js',
target: 'node',
externals: [nodeExternals()],
output: {
path: path.resolve(__dirname, 'build'),
filename: 'server.js',
publicPath: '/',
},
module: common,
plugins: [
new webpack.DefinePlugin({
__isBrowser__: 'false',
}),
],
};
module.exports = [clientConfig, serverConfig];
We will create two webpack configs, one for the client and the other for the server. We will import the packages we need and extract the common stuff like the js module bundling using babel-loader
in a separate variable. Then, we will create two separate variables holding the client and server config separately.
For the client, we have entry file at ./src/client/index.js and output at ./public/bundle.js. And for the server, we have entry file at ./src/server/index.js and output at ./build/server.js. One thing to notice is in both the config we are using webpack.DefinePlugin to create a global constant at the compile time. It will be used in our react components to check whether we are rendering on the server or the client and fetch the initial data accordingly. One last thing to talk about here is the externals
option in the server config. We will use the webpack-node-externals
package here to exclude external packages as mentioned earlier.
Now, we move onto our babel configuration
.bablerc
{
"presets": [
[
"env",
{
"targets": {
"browsers": ["last 2 versions"],
"node": "8.11.3"
}
}
],
"react",
"stage-2"
],
}
We will use the env preset with specific browser and node targets to simplify our transpiling needs. We will also use the react preset because we will be using JSX and stage-2 preset because we will be using features like Object spread and rest.
Lastly, we create the directory structure as per the webpack config, i.e. ./src/client and ./src/server to hold respective source files. We also create a common shared directory under src. It will hold all the components that will be accessed in both the cases.
Client
Now, let's dive into the code. We will start with the easy and familiar part, i.e. the presentational components (components that do not hold any business logic but only presentational UI related code).
Home.js
import React from 'react';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import Loading from './Loading';
const Home = ({ loading, data }) => (
<div className="home container">
<Helmet>
<title>FavMovies - Home</title>
</Helmet>
{loading ? <Loading /> : <Table movies={data} />}
</div>
);
const Table = ({ movies }) => (
<table className="table table-striped">
<thead>
<tr>
<th>Movie</th>
<th>Rating</th>
</tr>
</thead>
<tbody>{movies.map(movie => <Row key={movie.id} {...movie} />)}</tbody>
</table>
);
const Row = ({ id, title, rating }) => (
<tr>
<td>
<Link to={`/movie/${id}`}>{title}</Link>
</td>
<td>{rating}</td>
</tr>
);
export default Home;
Loading.js
import React from 'react';
const Loading = ({ size }) => <div className={size === 'lg' ? 'loading-lg' : 'loading'} />;
export default Loading;
Movie.js
import React from 'react';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import Loading from './Loading';
const Movie = ({ loading, data }) => (
<div className="post container">
<Helmet>
<title>FavMovies - {loading ? 'Movie' : data.title}</title>
</Helmet>
{loading ? (
<Loading />
) : (
<div className="card">
<div className="card-header">
<div className="card-title h5">{data.title}</div>
<div className="card-subtitle text-gray">IMDB Rating - {data.rating}</div>
</div>
<div className="card-body">{data.plot}</div>
<div className="card-footer">
<Link to="/" className="btn btn-primary">
Back to Home
</Link>
</div>
</div>
)}
</div>
);
export default Movie;
Navbar.js
import React from 'react';
import { Link } from 'react-router-dom';
const Navbar = () => (
<header className="navbar container">
<section className="navbar-section">
<span className="navbar-brand mr-2">FavMovies</span>
</section>
<ul className="tab tab-block">
<li className="tab-item">
<Link to="/" className="active">
Home
</Link>
</li>
</ul>
</header>
);
export default Navbar;
NoMatch.js
import React from 'react';
const NoMatch = () => <div className="route">No such route!!</div>;
export default NoMatch;
The Home
component accepts loading
and data
props and renders either a Loading
component or a Table
with a collection of movie title and rating. Each of the movie titles also link to their respective movie page i.e the Movie
component. The Movie
component also accepts the same props and renders a card UI with the movie title, rating and plot description. It also renders a button that links back to the home page. Both of them utilize the Helmet
component from react-helmet
to manipulate the <title>
tag based on the currently active route. Navbar
component renders a link to the home page as well as the app title. Loading
component renders a loader while NoMatch
is the 404 destination.
Next, we will look at the UniversalDataloader
(named so because it is responsible for loading the necessary data on both the server and the client) component that provides the data
props to the Home
and Movie
component. But let's first encapsulate all the necessary API fetch requests so that it's easier for us to understand the data retrieving process. fetchAllMovies
provides all movie listing and is hence used for populating the Home
component and fetchMovieById
is used for the Movie
component.
apiCalls.js
import axios from 'axios';
const baseUri = 'http://localhost:3002';
export const fetchAllMovies = () =>
axios
.get(`${baseUri}/movies`)
.then(({ data }) => data)
.catch(e => {
console.log(e);
return null;
});
export const fetchMovieById = id =>
axios
.get(`${baseUri}/movie/${id}`)
.then(({ data }) => data)
.catch(e => {
console.warn(e);
return null;
});
UniversalDataloader.js
import { Component } from 'react';
class UniversalDataloader extends Component {
constructor(props) {
super(props);
let data;
if (__isBrowser__ && window.__SERIALIZED_DATA__) {
data = window.__SERIALIZED_DATA__;
delete window.__SERIALIZED_DATA__;
} else if (this.props.staticContext) {
data = this.props.staticContext.data;
}
this.state = {
data,
loading: data ? false : true,
};
}
componentDidMount() {
if (!this.state.data) {
this.fetchData(this.props.match.params.id);
}
}
fetchData = id => {
this.setState({ loading: true });
this.props.getInitialData(id).then(data => {
this.setState({ data, loading: false });
});
};
render() {
return this.props.children(this.state);
}
}
export default UniversalDataloader;
This component utilizes the render props pattern to pass down necessary props to both the Home
and the Movie
component. The constructor is responsible for populating the initial data. It first checks whether it is the browser or the server. If it is the browser, it checks and fetches the data from window.__SERIALIZED_DATA__
which will be set on the server side (We will get back to this when we discuss server-side code). If it is the server, then we get the data from this.props.staticContext
that is also set during server-side render using StaticRouter
component from react-router-dom
. Finally, we setState
with the necessary data.
componentDidMount
is used to handle cases where the user navigates between the home and a movie route on the client-side. this.props.match.params.id
is provided by the react-router-dom
based on the route that is currently rendered to fetch the correct movie's data. In case of the home route, the id will be undefined
while in case of a movie route it will hold the corresponding movie id. The fetchData
method just wraps the getInitialData
method passed to the component via props for API request and also handles state update efficiently.
Finally, the render props pattern is used to pass down its state as props to the child component.
Next, we will setup all the routing logic for the application. One thing to note here is, we cannot declaratively implement the routes here like we usually do with client-side apps because our server needs to be aware of the routing logic as well. We will achieve this by storing our route related logic in a way accessible by both the server and the client.
routes.js
import Home from './components/Home';
import Movie from './components/Movie';
import { fetchAllMovies, fetchMovieById } from './apiCalls';
const routes = [
{
path: '/',
exact: true,
C: Home,
getInitialData: () => fetchAllMovies(),
},
{
path: '/movie/:id',
C: Movie,
getInitialData: (path = '') => fetchMovieById(path.split('/').pop()),
},
];
export default routes;
We list all the routes and the components that need to be rendered accordingly in this file. One thing to note is the custom getInitialData
props, this method abstracts away the specific API call needed to fetch the initial data. This is what enables our UniversalDataloader
to function regardless of what component is being rendered or what route it is.
We will now wrap all the constituents up in our App
component.
App.js
import React, { Component } from 'react';
import { Switch, Navlink, Route } from 'react-router-dom';
import Navbar from './components/Navbar';
import NoMatch from './components/NoMatch';
import UniversalDataloader from './components/UniversalDataloader';
import routes from './routes';
class App extends Component {
render() {
return (
<div className="app">
<Navbar />
<main>
<Switch>
{routes.map(({ path, exact, C, ...rest }) => (
<Route
key={path}
path={path}
exact={exact}
render={props => (
<UniversalDataloader {...props} {...rest}>
{dataProps => <C {...dataProps} />}
</UniversalDataloader>
)}
/>
))}
<Route render={props => <NoMatch {...props} />} />
</Switch>
</main>
</div>
);
}
}
export default App;
In this, we first render the Navbar
component, then we map over all the routes
from routes.js and render specific Route
components wrapped under Switch
component from react-router-dom
. We also render a fallback NoMatch
component for invalid routes. One thing to note is that we use nested render props to render the route specific component with data. We nest the Route
render props with our own UniversalDataloader
render props (flashback callback hell).
We finish up our client-side code by wrapping the App
component under BrowserRouter
from react-router-dom
. We also use ReactDOM.hydrate
instead of ReactDOM.render
here. This is so that client-side react can pick up from where the server left off. The server generates only the markup and the client-side uses that existing markup to attach only the event handlers to make the app interactive instead of rendering everything from scratch. This makes the process performant. This is also why server-side markup and client-side markup must always match up.
client.index.js
import React from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from '../shared/App';
hydrate(
<BrowserRouter>
<App />
</BrowserRouter>,
document.querySelector('#app')
);
Server
Last but definitely not the least, let's dive into the server rendering code.
server.index.js
import express from 'express';
import cors from 'cors';
import React from 'react';
import { Helmet } from 'react-helmet';
import { renderToString } from 'react-dom/server';
import { matchPath, StaticRouter } from 'react-router-dom';
import App from '../shared/App';
import routes from '../shared/routes';
import template from './template';
const app = express();
app.use(cors());
app.use(express.static('public'));
app.get('*', (req, res, next) => {
const activeRoute = routes.find(path => matchPath(req.path, path)) || {};
const apiResponse = activeRoute.getInitialData ? activeRoute.getInitialData(req.path) : Promise.resolve();
apiResponse
.then(data => {
const markup = renderToString(
<StaticRouter location={req.url} context={{ data }}>
<App />
</StaticRouter>
);
const title = Helmet.renderStatic();
res.send(template(data, markup, title));
})
.catch(next);
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`);
});
template.js
export default (data, markup, title) => `
<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<link rel="stylesheet" href="https://unpkg.com/spectre.css/dist/spectre.min.css">
<script>window.__SERIALIZED_DATA__ = ${JSON.stringify(data)}</script>
</head>
<body>
<div id="app">${markup}</div>
<script src="/bundle.js"></script>
</body>
</html>
`;
First, we initialize an express
app and setup cors
so that we can initiate Cross-Site Requests to our API. We also tell express
to serve static assets from the public directory. Then, we create a middleware function to intercept all traffic to the server and respond with the generated markup.
The middleware is responsible for generating the markup. Inside the middleware, we first determine the active route by using matchPath
function from react-router-dom
. This is the very function used internally by react-router to determine the correct active route. This ensures there can be no route resolution mismatch between the client and the server. Then, we use the getIntialData
method on the activeRoute
object to fetch the data necessary to populate the components. Upon response from the API, we use ReactDOM.renderToString
to generate the markup of our react app and also use Helmet.renderStatic
to render the <title>
tag markup (this is important, otherwise it will throw an error). One thing to note is that we wrap our App
under StaticRouter
here, because BrowserRouter
cannot be used on the server as it depends on certain browser APIs. We also pass location
props with req.url
to it. This is to maintain the client and server side markup match. We pass data
onto the context
props, because if you remember we use this.props.staticContext.data
to get data on the server for our Home
and Movie
components.
Once, we have all the necessary information we pass data
, markup
and title
to the template
function which spits out the complete HTML structure that is served as the server response. We set up the stringified data
value on window.__SERIALIZED_DATA__
for use in the Home
and Movie
components when rendering on the client.
Conclusion
Server-side rendering is a very powerful tool and must be used wisely. It is not a "cure-all" solution to performance problems in React apps. It has its downsides too. It can slow down an application if the markup needed to be generated on the server is heavy or the server workload is excessive. It is a tradeoff like any other implementation decision.
This tutorial is by no means a production-ready solution but it is a good starting point to understand how server-side rendering works. Feel free to fork and play around with the source code on GitHub.
This was very information and well-written. Thank you.
Thanks.