info@lebersoftware.hu

How to implement server-side rendering (SSR) in your React application with NodeJS – step by step tutorial

Itroduction

Could you imagine, that you can use the same React application in both server-side and client-side?

Is it possible? YES IT IS! 🙂

In this tutorial I will show you how can you render your React application on server-side. A NodeJS application (which uses Express) will render our App component into HTML with all the data (which need for the render) and send the whole HTML to the client side to the browser to be rendered.

Why is SSR good? Why wee need SSR?

Now imagine a webapplication, which has lots of “static” part, and has few options to interact with it. For e.g it renders articles and only have a search box in the top of the page. For example: https://csalad.hu/ . In this case it’s recommended to use server-side rendering.

Two benefits of using SSR:

  • Performance benefit for our customers
  • Consistent SEO performance

The first benefit is trivial, when the HTML arrives to the browser, it contains it’s final form, the React application is already rendered, so the HTML will be static, all the calculations run on server side.
From SEO aspect, the static HTML is the best for the search engine robots, because everything is avaliable when the page loaded, so the robots can easily parse the website.

Architecture

You can see the steps to render a React application on server side.
There are few steps:

  • Request arrived to the server
  • Get all the data which is needed to render the React application for the current root. For example if you are on the /home route, you need only for the data which is used on the home page. The API endpoint could be the same NodeJS application as the renderer application but in real life they will be different applications in most cases.
  • Render the React application into a HTML on the server side. The NodeJS app will pass all the data (from the step1) to the React application. This is the step where the NodeJS app setup the React app to use the current root, and other settings.
  • Server sending Ready to be rendered HTML Response to the browser
  • Browser renders the page. Now it’s viewable, and downloads all the resources (JS, CSS, …)
  • Browser executes the same React application (which is rendered on server side). It is important here that the React application’s state here should be equal to the final state of the React application which is rendered on the server side.
  • Page now interactable

Let’s get started – build our SSR React Application

The final source code is avaliable here: View code on GitHub

Download

(used create-react-app tool for React)

Unzip the project to any place on your computer, open it in VsCode and run npm install to install all the dependencies. On server side we will not have browser, so we need to install babel plugins, babel presets. We install express and some tool to make i18next working on the server side (middleware) and other packages for express. (you can find the whole list of dependencies in package.json)

.babelrc

You can find my .babelrc file in the root folder which is a configuration file of Babel.

{
    "presets": [
      ["@babel/preset-env", {
        "useBuiltIns": false,
      }],
    ],
    "plugins": [
      ["@babel/transform-runtime"] 
    ]
  }

Index.js (src/index.js)

import React, { Suspense } from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import "./i18n";
import { useSSR } from "react-i18next";
import App from "./App";

const AppContainer = () => {
  useSSR(window.initialI18nStore, window.initialLanguage);
  return (
    <Suspense fallback={<span>Loading...</span>}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </Suspense>
  );
};

// Suspense is not supported in SSR currently, and on server side we will use Static router, so this 2 is used here
ReactDOM.hydrate(<AppContainer />, document.getElementById("root"));

It’s very important to not use router inside the App which will be rendered, you should wrap your App with for e.g. BrowserRouter instead. This is because we will use Static router on server side and you know you cant use Router inside another Router, so this is the reason that the App component should not contains a Router!

The other thing that you must pay attention in index.js, that the suspense is used here as well. This is because SSR (Server side rendering) doesn’t support suspense at the moment, so App should not contains suspense too!

In this file we use the useSSR hook as well. This is the point where the React application gets the i18n settings from the NodeJS application (it will be described later).

In this file we import the i18n settings to (now with english language).

App.js, withSSR.js, context.js

The main component of our application.

import React, { useEffect } from "react";
import PropTypes from "prop-types";
import "./App.css";
import { CarProvider } from "./state/context";
import CarMain from "./view/index";
import i18n from "i18next";

const App = props => {
  useEffect(() => {
    // Remove SSR data after ComponentDidMount and i18n loaded
    if (i18n.isInitialized && window.ssrData) {
      window.ssrData = undefined;
    }
  });
  const { data } = props;

  return (
    <CarProvider ssrData={data}>
      <CarMain />
    </CarProvider>
  );
};

App.contextTypes = {
  data: PropTypes.array
};

export default App;

in line 9. I use a useEffect hook where I check that language files are loaded and the window.ssrData is exists. This object is coming from the server side via script which is injected in header. This is the data which is already fetched on server side and this data will be used as default state in the contexts.

!!!The component can get server-side data in two ways!!!:

  1. via window.ssrData (injected into header in script tag – loaded in browser)
  2. from props.data -> this will be used during server-side rendering (SSR), when the component will be rendered on server side!

In both cases, the ssrData or the data will be used as initial state in our contexts!!! For this purpose I made a Higher order component (HOC) -> withSSR, which is used in our context.js line 19:

...
...
const CarProvider = ({ initState, children }) => {
  const [state, dispatch] = useReducer(reducer, initState);
  return (
    <CarStateContext.Provider value={state}>
      <CarDispatchContext.Provider value={dispatch}>
        {children}
      </CarDispatchContext.Provider>
    </CarStateContext.Provider>
  );
};

CarProvider.propTypes = {
  initState: PropTypes.object.isRequired,
  children: PropTypes.node.isRequired
};

const CarProviderSSR = withSSR(CarProvider, initialState, "CarProvider");
export { useCarState, useCarDispatch, CarProviderSSR as CarProvider };

The withSSR (src/HOC/withSSR.js) decides in line 29 (here 5.), that what will be the initial state:

...
...
 let initState = { ...initialState };

      if (this.props.ssrData && this.props.ssrData) {
        initState = { ...this.setInitialStateSSR(this.props.ssrData), ssr: true };
      } else if (
        typeof window !== "undefined" &&
        window !== null &&
        window.ssrData
      ) {
        initState = this.setInitialStateSSR(window.ssrData);
      }
...
...

CarMain (src/view/index.js)

In this component I declared the routes. It well decide that which component will be rendered (CarList / Car)

import React, { Fragment } from "react";
import { Route } from "react-router-dom";
import CarList from "./CarList";
import Car from "./Car";

const CarMain = () => {
  return (
    <Fragment>
      <Route exact path={`/`} component={CarList} />
      <Route exact path={`/cars`} component={CarList} />
      <Route exact path={`/cars/:id`} component={Car} />
    </Fragment>
  );
};

export default CarMain;

CarList.js, Car.js components and useAsyncDataFetch, useEffect hooks

The CarList renders the cars and the Car component renders only one selected car. I would not write here the logic of these two component, because they are trivial, normal React components, just the important parts about SSR.

In both component we load the data asynchronously. I wrote a custom hook for this purpose, useSyncDataFetch hook:

...
...
const useAsyncDataFetch =  ({ promiseFn, dispatch }, ssr = false, params = {}  ) => {
    const [isLoading, setIsLoading] = useState(false);
    
    useEffectSSR(() => {
        if (ssr || (typeof window !== 'undefined' && window !== null && window.ssrData)){
            setIsLoading(false);
        } else {
            setIsLoading(true);
            promiseFn({ dispatch, ...params }).then(()=> {}).finally(()=>{
                 setIsLoading(false);
             });
        }
    }, [], promiseFn);

    return  { isLoading };
}
...
...

This hook checks that if the component rendered on server-side then it will set the isLoading flag to false because the data is already fetched on the server side (in NodeJs). This will happen in another case also if the component renders on client side initially because it will get the initial state too from the backend via window.ssrData in script tag!

In useEffectSSR hook I use a custom useEffect hook, my useEffectSSR, because the normal useEffect hook cannot be used in SSR, it’s content will not fire. So my useEffectSSR will check that is window object avaliable currently and if not, it will just run the content of the useEffect hook in myEffect (this will happen on server side):

...
...

/**
 * Custom useEffect, which will only call the content of useEffect hook once on server side
 * On client side it will work on it's original way
 */
export const useEffectSSR =
  typeof window === "undefined" ? myEffect : useEffect;

If the code runs on client side, for the data fetching I used axios, I have a service.js file and a provider.js file which provide a service layer for us to get data from the server. You can find the detailed introduction about them in my prevoios tutorials on this webpage: https://lebersoftware.hu/how-to-use-axios-react-async-context-api-react/


Server side (NodeJS application with Express)

I created the server application under server folder in our project structure. It’s just placed in the project structure of React application (which is created with create-react-app tool). The basic idea is to build the react application (with npm run build command) so the final client application is built into the ./build/ folder with all index.html and all assets. Then take this index.html, render the React application and put the rendered html content of it into the root div (<div id=”root”>) and send the whole html with other data to client.

In my package.json file, you can find a command, which will run the server application: “node server/bootstrap.js”

...
...
"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "node server/bootstrap.js"
  },
...
...

Bootstrap.js (server/bootstrap.js)

In this file, I require the required packages, and register babel. At the end of the file I require the server.js which contains the express server application.

...
...
require('ignore-styles');
require('url-loader');
require('file-loader');
require('@babel/register')({
    ignore: [ /(node_modules)/ ],
    presets: ['@babel/preset-env', '@babel/preset-react'],
    plugins: [
        'syntax-dynamic-import',
        'dynamic-import-node'
    ]
});
require('./server');
...
...

Server.js (server/server.js)

In this file I write the basic logic of the server application which will render our React application server side.

In this first line I init the express aplication, then init i18n on server side (for e.g. setup the load path to load translation.json later). Setup the i18n middleware.

...
...
// Init Express app
var app = express();

//Init i18n
i18next.use(middleware.LanguageDetector).use(nodeFsBackend).init({
  lng: "en",
  fallbackLng: "en",
  lowerCaseLng: true,
  preload: ["en"],
  backend: {
    loadPath: path.join(`${__dirname}/../build/locales/en/translation.json`)
  },
  useCookie: false
});

//setup i18n for app
app.use(
  middleware.handle(i18next, {
    removeLngFromUrl: false
  })
);
...
...

in the next lines I setup the port (9000) and routes and I finally start the whole application. Just for test, I setup some API endpoint here now (to fetch cars, and a specific car as json).

The important part here is the main routes, which should render the react application:

...
...
const actionIndex = (req, res, next) => {
  serverRenderer()(req, res, next);
};

// root (/) should always serve our server rendered page
router.use("^/$", actionIndex);
router.use("^/cars$", actionIndex);
router.use("^/cars/[0-9]+$", actionIndex);
...
...

As you can see above, it will render the React application server-side in case of 3 routes: /, /cars and /cars/{carId}

The serverRenderer() is a function which will render the react application and produce the final response HTML.

Renderer.js (./server/middleware/renderer.js)

This is the file, which contains the render function which will produce the final rendered HTML.

...
...
// import our main App component
import App from "../../src/App";

// import the manifest generated with the create-react-app build
import manifest from "../../build/asset-manifest.json";

// function to extract js assets from the manifest
const extractAssets = (assets, chunks) =>
  Object.keys(assets)
    .filter(asset => chunks.indexOf(asset.replace(".js", "")) > -1)
    .map(k => assets[k]);

const path = require("path");
const fs = require("fs");
...
...

First it imports the App React component from it’s original place (src folder) and the manifest from the build folder.

I created an extractAssets function which will extract js assets from the ./build/asset-manifest.json

...
...
export default () => (req, res, next) => {
  // get the html file created with the create-react-app build
  const filePath = path.resolve(__dirname, "..", "..", "build", "index.html");

  fs.readFile(filePath, "utf8", async (err, htmlData) => {
    if (err) {
      console.error("err", err);
      return res.status(404).end();
    }

    const modules = [];

    // Get all initial data

    const data = await getAllInitialData(req.baseUrl);

    const context = {};

    // render the app as a string
    const html = ReactDOMServer.renderToString(
      <I18nextProvider i18n={req.i18n}>
        <StaticRouter location={req.baseUrl} context={context}>
          <App data={data} />
        </StaticRouter>
      </I18nextProvider>
    );

    // Setup i18n for React component on client side after DOM is ready on client side
    const initialI18nStore = {};
    req.i18n.languages.forEach(l => {
      initialI18nStore[l] = req.i18n.services.resourceStore.data[l];
    });
    const initialLanguage = req.i18n.options.lng;
...
...

in the render function above first I load the index.html content into htmlData variable. In line 17., I load all the required data for the current root into data variable. This data will be passed to the App component and will be processed by the withSSR hook in context.js as initial state.

I used the I18nextProvider which is used to push the request’s i18n settings to the React application. After that I setup the router which is a StaticRouter here. I setup the location which is the request’s baseUrl and an empty object as context.

I include the App component and push the data in props.data to it. (you can see that how will the client handle this data in the client side’s description)

The ReactDOMServer.renderToString will render the whole App component into html variable.

...
...
 // map required assets to script tags
    const extraChunks = extractAssets(manifest, modules).map(
      c => `<script type="text/javascript" src="/${c}"></script>`
    );

    // get HTML headers
    const helmet = Helmet.renderStatic();

    // now inject the rendered app into our html and send it to the client
    const resx = res.send(
      htmlData
        .replace(
          '<div id="root"></div>',
          `<b>Rendered on server side:</b><br/><br/> <div id="root">${html}</div>`
        )
        // append the extra js assets
        .replace("</body>", extraChunks.join("") + "</body>")
        // write the HTML header tags
        .replace(
          "<title></title>",
          helmet.title.toString() + helmet.meta.toString()
        )
        .replace(
          "<noscript>You need to enable JavaScript to run this app.</noscript>",
          ""
        )
        .replace(
          "</head>",
          "<script>" +
            " var ssrData = " +
            JSON.stringify(data) +
            ";" +
            " var initialI18nStore  = " +
            JSON.stringify(initialI18nStore) +
            ";" +
            " var initialLanguage  = " +
            JSON.stringify(initialLanguage) +
            ";" +
            "</script>" +
            "</head>"
        )
    );

    return resx;
...
...

Above you can see, that the rendered component is placed inside div with id root. The fetched data will be injected to script tag in header in the index.html (window.ssrData variable) and after the page loaded on client side it will be used for initial state with the withSSR hook in context.js. We send the initialI18nStore and initialLanguage to i18n as basic setting to prevent the load of language files on first page load.

Services.js (./server/middleware/services.js)

In this file I setup the configuration of the services. This configuration contains the routes and that which data should be fetched for which route. It will fetch all the data using fetch and Promise.all.


Conclusion

It’s not an easy procedure to render a React application on server-side, but it’s not a hard procedure as well, if you know the right technique to do it.

With little modification, the same React application can be rendered on server-side and can be used on the client side as well and this is the goal!

Start the application with the npm run server command.

Finally, here is the screenshot of the application when I call the http://localhost:9000 in Chrome. You can see that there isn’t any XHR request called when it’s loaded, because the cars and employee list fetched server-side and the React application is rendered server-side to using the data.

Leave a Reply