Server-Side Rendering from zero to hero

alexsergey

Sergey

Posted on January 19, 2021

Server-Side Rendering from zero to hero
  • 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 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 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.

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

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

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.

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')
);
Enter fullscreen mode Exit fullscreen mode

This code renders a list of completed tasks using the jsonplaceholder service to emulate API interaction.

Let's move the application to SSR!

Step 1. Install dependencies

To install iSSR you need to do:

npm install @issr/core --save
npm install @issr/babel-plugin --save-dev
Enter fullscreen mode Exit fullscreen mode

Install dependencies for the webpack 5 build system:

npm install @babel/core @babel/preset-react babel-loader webpack webpack-cli nodemon-webpack-plugin --save-dev
Enter fullscreen mode Exit fullscreen mode

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:*

npm install node-fetch --save
Enter fullscreen mode Exit fullscreen mode

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

npm install express --save
Enter fullscreen mode Exit fullscreen mode

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

npm install serialize-javascript --save
Enter fullscreen mode Exit fullscreen mode

Step 2. Configuring webpack.config.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',
    }
  }
];
Enter fullscreen mode Exit fullscreen mode
  • 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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

client.jsx:

import React from 'react';
import { hydrate } from 'react-dom';
import { App } from './App';

hydrate(
  <App />,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

We changed the default React render method to hydrate, which works for SSR applications.

server.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!');
});
Enter fullscreen mode Exit fullscreen mode

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 package:

App.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>
  );
};
Enter fullscreen mode Exit fullscreen mode

In server.jsx, replace the standard renderToString with the serverRender from the @issr/core package:

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!');
});
Enter fullscreen mode Exit fullscreen mode

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 package :

App.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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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')
);
Enter fullscreen mode Exit fullscreen mode

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:

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!');
});
Enter fullscreen mode Exit fullscreen mode

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:

"scripts": {
 "start": "webpack -w --mode development",
 "build": "webpack"
},
Enter fullscreen mode Exit fullscreen mode

Redux and other State Management libraries

iSSR perfectly supports various state management libraries. While working on 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.

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

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:

useSsrEffect(() => {
 dispatch(fetchImage());
});
Enter fullscreen mode Exit fullscreen mode

This is not an asynchronous operation! But iSSR realizes that something has happened in the system. iSSR will go through the rest of the React components doing all the useSsrEffect and upon completion of the iSSR will call the callback:

const { html } = await serverRender(() => (
 <Provider store={store}>
   <App />
 </Provider>
), async () => {
 store.dispatch(END);
 await rootSaga.toPromise();
});
Enter fullscreen mode Exit fullscreen mode

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, you can find them here.

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. 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
React-Meta-Tags
I have prepared some examples:
React-Helmet-Async
React-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 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:

if (typeof windows === 'undefined') {
}
Enter fullscreen mode Exit fullscreen mode

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

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

Debugging

Debugging on the server can be performed just like any debugging of node.js applications via inpsect.
To do this, add to the webpack.config for the nodejs application:

devtool: 'source-map'
Enter fullscreen mode Exit fullscreen mode

And in the NodemonPlugin settings:

new NodemonPlugin({
  watch: path.resolve(__dirname, './dist'),
  nodeArgs: [
    '--inspect'
  ]
})
Enter fullscreen mode Exit fullscreen mode

Also, to improve work with the source map, you can add the module

npm install source-map-support --save-dev
Enter fullscreen mode Exit fullscreen mode

In nodeArgs of NodemonPlugin options add:
‘--Require =“ source-map-support / register ”’
Example

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
sitechecker
pagespeed

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!

💖 💪 🙅 🚩
alexsergey
Sergey

Posted on January 19, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related