Server-Side Rendering from zero to hero
Tags: react, webdev, javascript, tutorial
Published: 2021-01-19 07:45:40

- In this article, we will analyze the impact of SSR on SEO optimization of the application.
- We will walk with you through the way of porting a regular **React** application to SSR.
- We will look at the processing of asynchronous operations in SSR applications.
- We will see how to do SSR in applications with **Redux Saga**.
- We will configure **Webpack 5** to work with an SSR application.
- We will also consider the intricacies of SSR: Generating **HTML Meta Tags**, **Dynamic Imports**, working with **LocalStorage**, **debugging**, and more.

A couple of years ago, while working on our [Cleverbrush](https://www.cleverbrush.com/) product, a friend of mine and I faced an SEO optimization problem. We created the website, which in theory, was supposed to sell our product, and it was a regular Single Page React Application, hasn't appeared in Google search results! In the course of several detailed analyses, the [iSSR](https://github.com/AlexSergey/issr) library was born, and our site finally appeared on the first page of Google search results. So let us work this out!

## The Problem

The main problem with Single Page applications is that the server gives back a blank HTML page to the client. Its formation occurs only after all JS has been downloaded (this is all your code, libraries, framework). In most cases, this is more than 2 megabytes in size + code processing delays.

Even if a Google-bot knows how to execute JS, it only receives content after some time, which is critical for the site's ranking. Google-bot simply sees a blank page for a few seconds! This is a bad thing!

Google starts issuing red cards if your site takes more than 3 seconds to render. First Contentful Paint, Time to Interactive are metrics that will be underestimated with Single Page Application. Read more [here](https://developers.google.com/web/updates/2019/02/rendering-on-the-web?hl=en).

There are also less advanced search engines that simply do not know how to work with JS. They will not index the Single Page Application.

Many factors still affect the ranking rate of a site, some of which we will analyze later in this article.

## Rendering

There are several ways to solve the problem of a blank page when loading, consider a few of them:

**Static Site Generation (SSG)**. Make a pre-render of the site before uploading it to the server. A very simple and effective solution. Great for simple web pages, no backend API interaction.

**Server-Side Rendering (SSR)**. Render content at runtime on the server. With this approach, we can make backend API requests and serve HTML along with the necessary content.

## Server-Side Rendering (SSR)

Let's take a closer look at how SSR works:

- We need to have a server that executes our application exactly as a user would do in a browser. Making requests for the necessary resources, rendering all the necessary HTML, filling in the state.

- The server gives the client the full HTML, the full state, and also gives all the necessary JS, CSS, and other resources.

- The client receives HTML and resources, synchronizes the state, and works with the application as with a normal Single Page Application. The important point here is that the state must be synchronized.

A schematic SSR application looks like this:

![iSSR](http://natrube.net/issr/schema.png)

From previously described SSR work, we can highlight the following **problems**:

- The application is divided into the server and client sides. That is, we essentially get 2 applications. This separation should be minimal, otherwise, support for such application will be difficult.

- The server should be able to handle API requests with data. These operations are asynchronous and are called **Side Effects**. By default, React's **renderToString** server-side method is synchronous and cannot handle asynchronous operations.

- On the client, the application must sync state and continue to work as a normal SPA application.

## [iSSR](https://github.com/AlexSergey/issr)

This is a small library that can solve the problems of asynchronous processing of requests for data and synchronization of state from the server to client. This is not another **Next.JS** killer, no! **Next.JS** is a great framework with many features, but in order to use it, you will need to completely rewrite your application and follow the rules of **Next.JS**.

Let's look at the example of how easy it is to port a regular SPA application to SSR.

For example, we have a simple application with asynchronous logic.

```jsx
import React, { useState, useEffect } from 'react';
import { render } from 'react-dom';

const getTodos = () => {
  return fetch('https://jsonplaceholder.typicode.com/todos')
    .then(data => data.json())
};

const TodoList = () => {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    getTodos()
      .then(todos => setTodos(todos))
  }, []);

  return (
    <div>
      <h1>Hi</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
        ))}
      </ul>
    </div>
  )
}

render(
  <TodoList />,
  document.getElementById('root')
);
```
This code renders a list of completed tasks using the [jsonplaceholder](https://jsonplaceholder.typicode.com/) service to emulate API interaction.

## Let's move the application to SSR!

### Step 1. Install dependencies

To install [iSSR](https://github.com/AlexSergey/issr) you need to do:

```sh
npm install @issr/core --save
npm install @issr/babel-plugin --save-dev
```

Install dependencies for the webpack 5 build system:

```sh
npm install @babel/core @babel/preset-react babel-loader webpack webpack-cli nodemon-webpack-plugin --save-dev
```

*One of the non-obvious aspects of SSR application development is that some APIs and libraries can work on the client but not on the server. One such API is **fetch**. This method is absent in **nodejs** where the server logic of our application will be executed. In order to work the same here, install the package:*

```sh
npm install node-fetch --save
```

We will use **express** for the server, but it doesn't matter, you can use any other framework:

```sh
npm install express --save
```

Let's add a module for serializing the application state on the server:

```sh
npm install serialize-javascript --save
```

### Step 2. Configuring webpack.config.js

```js
const path = require('path');
const NodemonPlugin = require('nodemon-webpack-plugin');

const commonConfig = {
  module: {
    rules: [
      {
        test: /\.jsx$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                '@babel/preset-react'
              ],
              plugins: [
                '@issr/babel-plugin'
              ]
            }
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: [
      '.js',
      '.jsx'
    ]
  }
}

module.exports = [
  {
    ...commonConfig,
    target: 'node',
    entry: './src/server.jsx',
    output: {
      path: path.resolve(__dirname, './dist'),
      filename: 'index.js',
    },
    plugins: [
      new NodemonPlugin({
        watch: path.resolve(__dirname, './dist'),
      })
    ]
  },
  {
    ...commonConfig,
    entry: './src/client.jsx',
    output: {
      path: path.resolve(__dirname, './public'),
      filename: 'index.js',
    }
  }
];
```

- To compile an SSR application, the **webpack** config file must consist of two configurations (MultiCompilation). One for building the server, the other for building the client. We are passing an array to *module.exports*.

- To configure the server, we need to set *target: 'node'*. Target is an optional for the client. By default, webpack config has target: ‘web’. target: 'node' allows webpack to handle server code, default modules such as path, child_process, and more.

- *const commonConfig* - common part of the settings. Since the server and client code share the same application structure, they must handle JS the same way.

You need to add a plugin to babel-loader:
*@issr/babel-plugin*

This is a helper *@issr/babel-plugin* that allows you to track asynchronous operations in your application. Works great with **babel/typescript-preset**, and other babel plugins.

### Step 3. Modification of the code.

Let's move the general logic of our application into a separate file **App.jsx**. This is necessary so that only the rendering logic remains in the **client.jsx** and **server.jsx** files, nothing else. Thus, we will have the entire application code in common.

**App.jsx**:
```jsx
import React from 'react';
import fetch from 'node-fetch';
import { useSsrState, useSsrEffect } from '@issr/core';

const getTodos = () => {
  return fetch('https://jsonplaceholder.typicode.com/todos')
    .then(data => data.json())
};

export const App = () => {
  const [todos, setTodos] = useSsrState([]);

  useSsrEffect(async () => {
    const todos = await getTodos()
    setTodos(todos);
  });

  return (
    <div>
      <h1>Hi</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
};
```

**client.jsx**:
```jsx
import React from 'react';
import { hydrate } from 'react-dom';
import { App } from './App';

hydrate(
  <App />,
  document.getElementById('root')
);
```
We changed the default React **render** method to **hydrate**, which works for SSR applications.

**server.jsx**:
```jsx
import React from 'react';
import express from 'express';
import { renderToString } from 'react-dom/server';
import { App } from './App';

const app = express();

app.use(express.static('public'));

app.get('/*', async (req, res) => {
const html = renderToString(<App />);

  res.send(`
  <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <div id="root">${html}</div>
</body>
</html>
`);
});

app.listen(4000, () => {
  console.log('Example app listening on port 4000!');
});
```
In the server code, note that we have to share the folder with the built SPA webpack application:
*app.use (express.static ('public'));*
Thus, the HTML received from the server will continue to work as a regular SPA

### Step 4. Handling asynchronous functions.

We have separated the common part of the application, connected the compiler for the client and server parts of the application. And now let's solve the rest of the problems associated with asynchronous calls and state.

To handle asynchronous functions, you need to wrap them in the **useSsrEffect** hook from the [@issr/core](https://github.com/AlexSergey/issr) package:

**App.jsx**:
```jsx
import React from 'react';
import fetch from 'node-fetch';
import { useSsrEffect } from '@issr/core';

const getTodos = () => {
  return fetch('https://jsonplaceholder.typicode.com/todos')
    .then(data => data.json())
};

export const App = () => {
  const [todos, setTodos] = useState([]);

  useSsrEffect(async () => {
    const todos = await getTodos()
    setTodos(todos);
  });

  return (
    <div>
      <h1>Hi</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
};
```

In server.jsx, replace the standard **renderToString** with the **serverRender** from the [@issr/core](https://github.com/AlexSergey/issr) package:

```jsx
import React from 'react';
import express from 'express';
import { serverRender } from '@issr/core';
import serialize from 'serialize-javascript';
import { App } from './App';

const app = express();

app.use(express.static('public'));

app.get('/*', async (req, res) => {
  const { html } = await serverRender(() => <App />);

  res.send(`
  <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <div id="root">${html}</div>
    <script src="/index.js"></script>
</body>
</html>
`);
});

app.listen(4000, () => {
  console.log('Example app listening on port 4000!');
});
```

If you run the application straight away, nothing will happen! **We will not see the result of executing the getTodos asynchronous function**. Why not? We forgot to sync state. Let's fix this.

In App.jsx, replace the standard **setState** with **useSsrState** from the [@issr/core](https://github.com/AlexSergey/issr) package :

**App.jsx**:
```jsx
import React from 'react';
import fetch from 'node-fetch';
import { useSsrState, useSsrEffect } from '@issr/core';

const getTodos = () => {
  return fetch('https://jsonplaceholder.typicode.com/todos')
    .then(data => data.json())
};

export const App = () => {
  const [todos, setTodos] = useSsrState([]);

  useSsrEffect(async () => {
    const todos = await getTodos()
    setTodos(todos);
  });

  return (
    <div>
      <h1>Hi</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
};
```

Let's make changes to client.jsx to synchronize the state transferred from the server to the client:

```jsx
import React from 'react';
import { hydrate } from 'react-dom';
import createSsr from '@issr/core';
import { App } from './App';

const SSR = createSsr(window.SSR_DATA);

hydrate(
  <SSR>
    <App />
  </SSR>,
  document.getElementById('root')
);
```

**window.SSR_DATA** is an object passed from the server with a cached state for synchronization on the client.

Let's make the transfer state on the server:

```jsx
import React from 'react';
import express from 'express';
import { serverRender } from '@issr/core';
import serialize from 'serialize-javascript';
import { App } from './App';

const app = express();

app.use(express.static('public'));

app.get('/*', async (req, res) => {
  const { html, state } = await serverRender(() => <App />);

  res.send(`
  <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script>
      window.SSR_DATA = ${serialize(state, { isJSON: true })}
    </script>
</head>
<body>
    <div id="root">${html}</div>
    <script src="/index.js"></script>
</body>
</html>
`);
});

app.listen(4000, () => {
  console.log('Example app listening on port 4000!');
});
```

Please note that the **serverRender** function passes not only HTML but also the state that passed through **useSsrState**, we pass it to the client as a global variable **SSR_DATA**. On the client, this state will be automatically synchronized.

### Step 5. Build scripts

It remains to add scripts to **package.json**:

```js
"scripts": {
 "start": "webpack -w --mode development",
 "build": "webpack"
},
```

## Redux and other State Management libraries

[iSSR](https://github.com/AlexSergey/issr) perfectly supports various state management libraries. While working on [iSSR](https://github.com/AlexSergey/issr), I noticed that React State Management libraries are divided into 2 types:

- Implements working with Side Effects on a React layer. For example, **Redux Thunk** turns the Redux dispatch call into an asynchronous method, which means we can implement SSR as in the example above for setState. The example of redux-thunk is available [here](https://github.com/AlexSergey/issr/tree/master/examples/10.1-redux-thunk).

- Implement working with Side Effects on a separate layer from React. For example, **Redux Saga** brings work with asynchronous operations to Sagas.

Let's look at the example of SSR implementation for an application with **Redux Saga**.

*We will not consider this example in as much detail as the previous one. The complete code can be found [here](https://github.com/AlexSergey/issr/tree/master/examples/10-redux-sagas).*

## Redux Saga

*For a better understanding of what is happening, read the previous chapter*

The server runs our application through **serverRender**, the code is executed sequentially, performing all the **useSsrEffect** operations.

Conceptually, **Redux** does not perform any asynchronous operations when working with sagas. Our task is to send an action to start an asynchronous operation in the Cag layer, separate from our react-flow. In the example from the link above, in the **Redux** container, we execute:

```js
useSsrEffect(() => {
 dispatch(fetchImage());
});
```

This is not an asynchronous operation! But [iSSR](https://github.com/AlexSergey/issr) realizes that something has happened in the system. [iSSR](https://github.com/AlexSergey/issr) will go through the rest of the React components doing all the **useSsrEffect** and upon completion of the [iSSR](https://github.com/AlexSergey/issr) will call the callback:

```jsx
const { html } = await serverRender(() => (
 <Provider store={store}>
   <App />
 </Provider>
), async () => {
 store.dispatch(END);
 await rootSaga.toPromise();
});
```

Thus, we can process asynchronous operations not only on the React level but on other levels too, in this case, firstly we put the sagas we need to execute, then which we started the **serverRender** callback and wait for them to end.

I have prepared many examples of using [iSSR](https://github.com/AlexSergey/issr), you can find them [here](https://github.com/AlexSergey/issr/tree/master/examples).

## SSR tricks

There are many challenges along the way in developing SSR applications. The problem of asynchronous operations is just one of them. Let's take a look at other common problems.

### HTML Meta Tags for SSR
An important aspect of SSR development is using correct [HTML meta tags](https://www.w3schools.com/tags/tag_meta.asp). They tell the search bot the key information on the page.
To accomplish this task, I recommend you to use one of the modules:
[React-Helmet-Async](https://github.com/staylor/react-helmet-async)
[React-Meta-Tags](https://github.com/s-yadav/react-meta-tags)
I have prepared some examples:
[React-Helmet-Async](https://github.com/AlexSergey/issr/tree/master/examples/5.1-meta-tags-helmet)
[React-Meta-Tags](https://github.com/AlexSergey/issr/tree/master/examples/5-meta-tags)

### Dynamic Imports
To reduce the size of the final application bundle, the application can be divided into parts. For example, **dynamic imports webpack** allows you to automatically split your application. We can move individual pages into chunks. With SSR, we need to be able to handle the data pieces of the application as a whole. To do this, I recommend using the wonderful [@loadable](https://loadable-components.com/docs/server-side-rendering/) module.

### Dummies

Some components may not be rendered on the server. For example, if you have a post and comments, it is not advisable to handle both asynchronous operations. Post data takes precedence over comments to it, it is this data that forms the SEO load of your application. Therefore, we can exclude unimportant parts using type checks:
```js
if (typeof windows === 'undefined') {
}
```

### localStorage, data storage

NodeJS doesn't support localStorage. We use cookies instead of localStorage to store session data. Cookies are sent automatically on every request. Cookies have limitations, for example:

- Cookies are an old way of storing data, they have a limit of 4096 bytes (actually 4095) per cookie.

- localStorage is an implementation of the storage interface. It stores data without an expiration date and is only cleared by JavaScript or clearing browser cache/locally stored data - as opposed to cookie expiration.

Some data needs to be passed in the URL. For example, if we use localization on the site, then the current language will be part of the URL. This approach will improve SEO as we will have different URLs for different localizations of the application and provide data transfer on demand.

### React Server Components

React Server Components might be a good addition to SSR. Its idea is to reduce the load on the Bundle by executing the components on the server and issuing a ready-made JSON React tree. We saw something similar in **Next.JS**. Read more at the [link](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html)

### Routing
**React Router** supports SSR out of the box. The difference is that on the server the **StaticRouter** is used with the current URL passed, and on the client **Router** determines the URL automatically using the location API. [Example](https://github.com/AlexSergey/issr/tree/master/examples/4-koa-react-router)

### Debugging
Debugging on the server can be performed just like any debugging of node.js applications via [inpsect](https://nodejs.org/en/docs/guides/debugging-getting-started/).
To do this, add to the **webpack.config** for the nodejs application:
```js
devtool: 'source-map'
```
And in the **NodemonPlugin** settings:
```js
new NodemonPlugin({
  watch: path.resolve(__dirname, './dist'),
  nodeArgs: [
    '--inspect'
  ]
})
```
Also, to improve work with the source map, you can add the module
```sh
npm install source-map-support --save-dev
```
In *nodeArgs* of **NodemonPlugin** options add:
*‘--Require =“ source-map-support / register ”’*
[Example](https://github.com/AlexSergey/issr/tree/master/examples/18-debugging)

### Next.JS

If you are building an application from scratch, I recommend you to pay attention to this framework. It is currently the most popular solution for building SSR-enabled applications from scratch. One of the advantages is that everything comes out of the box (build system, router). The minus - it is necessary to rewrite the existing application, use the **Next.JS** approaches.

### SEO isn't just about SSR!

Google bot SEO criteria include many metrics. Renders data, gets the first byte, etc. this is just a part of the metrics! When SEO optimization of the application, it is necessary to minimize image sizes, bundles, use HTML tags and HTML meta tags correctly, and so on.
To check your site for SEO optimization, you can use:
[lighthouse](https://github.com/GoogleChrome/lighthouse)
[sitechecker](https://sitechecker.pro/)
[pagespeed](https://developers.google.com/speed/pagespeed/insights/)

### Conclusion

In this article, I have described the main problems, but not all of developing SSR applications. But the purpose of this article is to show you that SSR is not that bad. With this approach, we can live and make great apps! I wish everyone who has read to the end successful and interesting projects, fewer bugs, and good health at this difficult time for all of us!