React Server-Side Rendering from Scratch

We will implement a basic server-side rendering React app from scratch using express.

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 -

  1. 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.

  2. 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.


Hami Abdi picture

This was very information and well-written. Thank you.