• Fast Setup
  • Articles:
  • SSR part 1. Creating simple SSR application
  • SSR part 2. Migration legacy app to SSR
  • SSR part 3. Advanced Techniques
  • Log Driven Development
  • Localization. True way
Rockpack

SSR part 1. Creating simple SSR application

Working for a long time in outsource, a person grows outsourcer-syndrome. What it is? We create products without worrying about their future fate. A customer came, ordered an application, a website, whatever, we happily did it, assembled it using ultra-modern technologies, approaches, putting our souls into this project ... But after completing the work, the customer leaves. On his place comes another. With another product, etc. This is a pipeline! In such situation, we do not know whether the customer's business is successful after release, and if we achieved the business goals. Does the audience like the product, etc?

I also had an outsourcer-syndrome. When we were making our product, I had 8+ years of experience in outsource. And now the time has come to realize this. We have released our product Cleverbrush. As time passed, I noticed that the main site, our selling platform, was simply not searched. Nobody visited it. It was absent in Google and other search engines results.

It's time to understand that when we finish a project, business starts and all our technical solutions can both positively and negatively affect profits.

What was the problem, why users couldn't find the site? It was just a simple Single Page React application, making multiple requests, rendering a nice landing page. So what? Most of the modern web is Single Page applications. Yes! But, wondering why this is happening, I looked at Google PageSpeed and the results were pure.

The problem

The main problem with Single Page applications is that the server serves a blank HTML page to the client. It's formation occurs only after all JS has been downloaded (this is all your code, libraries, framework). It is advisable to load the styles as well so that the page does not "jump". In most cases, this is more than 2 megabytes in size + code processing delays.

Even if a Google bot is able to parse a Single Page application, it only receives content after some time, which is critical for the ranking of the site. The Google bot simply sees a white, blank page for a few seconds! This is a bad thing!

Our audience was not limited to the US market. Residents of Russia, Ukraine, Belarus, etc. can become our clients to. In these countries, the Yandex search engine is widely used, which does not know how to render Single Page applications (by that time when we encountered the problem). There are many other search engines out there, and I was surprised that the traffic from them is very, very high. I never thought about it before! These people will never know about our product, we will not get potential customers, etc.

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 necessarily be underestimated with Single Page Application.

But where does this figure of 2-3 seconds come from? I heard about it somewhere, hmm... Many articles in UX refer to this figure. And the answer covered in the people. In the modern world, everyone has become a little lazy. Everyone was overfilled with information. And the person no longer intends to wait more than two seconds for your site to load! What is it, we are wasting time of his life while he watching the loading process of our stunning site!

A bunch of factors are also related to site rankings. I will tell you about them in other articles. Let's focus on the rendering issue.

Solving...

So, there are several ways to solve the problem of a blank page when loading.

Solution 1. This should be done by the site pre-renderer before uploading it to the server. A very simple and effective solution. If we do not need to download additional important information during the execution of the application. What I mean is, for example, we have an API to get a list of posts on a page. When executed, the application makes an API request, gets a list of posts, and displays them. With a pre-renderer, we will not be able to make such a request. Therefore, only the frame of the page will be available withoutUSEFUL INFORMATION. And it is really necessary for both the user and the search engines (it makes sense for search bots to crawl our site if there is no information that is needed there).

Solution 2. Render content at runtime on the server (Server Side Rendering). With this approach, we will be able to make requests where needed and serve HTML along with the necessary content.

There are also other ways, but we will not consider them. These 2 solutions clearly demonstrate one of the problems.

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 and works with the application as a normal Single Page Application. The important point here is that the state must be synchronized.

Problems

From the above described SSR application work, we can highlight the problems:

  • The application is divided into server and client. That is, we essentially get 2 applications. In order for this to be supported, this separation must be minimal
  • The server must be able to query data. For example, we have an API with posts, we need to make a request to this API, get the data, insert the data into the application, and get the HTML with the current state. The problem at this point is that the renderToString supplied with React is synchronous. And the operations are asynchronous.
  • Our server is essentially a React application. This means that it will contain import/export features, JSX, possibly TypeScript and so on. The server will also have to be handled through Babel or TS just like the main application.
  • On the client, the application must sync state and continue to work like a normal SPA application.

API requests

Let's talk about this topic in more detail. The point is that such requests can depend on each other. And after each request, we have to update the state of the application and rerun it through renderToString.

Example:

We have a request for posts to the API, we receive data, after which we need to make a request for brief information for each post. This behavior is called the effect queue. Thus, we need to pass our application through renderToString until all the necessary data has been downloaded.

Schematically it looks like this:

Server Side Rendering

Go!

All these problems are covered by the rockpack/ussr module and rockpack/compiler. For example, we will create an application from a simple, simple SSR application.

Suppose we have a simple application with asynchronous logic

import React, { render, useState, useEffect } from 'react';
const asyncFn = () => new Promise((resolve) => setTimeout(() => resolve({ text: 'Hello world' }), 1000));
export const App = () => {
const [state, setState] = useState({ text: 'text here' });
useEffect(() => {
asyncFn()
.then(data => setState(data))
}, []);
return (
<div>
<h1>{state.text}</h1>
</div>
);
};
render(
<App />,
document.getElementById('root')
);

In place of the asyncFn method, we can have a request to the server.

Let's make this application asynchronous!

1. Installation

--> @rockpack/compiler is an optional module! If you have your own webpack.config or you want to build it from scratch you can use this approach

npm install @rockpack/ussr --save
npm install @rockpack/compiler --save-dev

Let's create a build.js file at the root of our project. It will allow us to compile our client and server, processing TS, JSX, various resources such as SVG, images, and more.

const { isomorphicCompiler, backendCompiler, frontendCompiler } = require('@rockpack/compiler');
isomorphicCompiler(
frontendCompiler({
src: 'src/client.jsx',
dist: 'public',
}),
backendCompiler({
src: 'src/server.jsx',
dist: 'dist',
})
);

Launch commands:

cross-env NODE_ENV=development node build.js
cross-env NODE_ENV=production node build.js

--> @rockpack/compiler is an optional module! If you have your own webpack.config or you want to build it from scratch you can use this approach

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, the entire application code will be common to us.

App.jsx:

import React from 'react';
const asyncFn = () => new Promise((resolve) => setTimeout(() => resolve({ text: 'Hello world' }), 1000));
export const App = () => {
const [state, setState] = useState({ text: 'text here' });
useEffect(() => {
asyncFn()
.then(data => setState(data))
}, []);
return (
<div>
<h1>{state.text}</h1>
</div>
);
};

client.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:

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!');
});

It is not yet an SSR application, but it will soon be.

@rockpack/ussr

We separated the logic by bringing out the common part, connected the compiler for the client and server parts of the application, taking into account JSX / TS and so on. Now let's solve the rest of the problems associated with asynchronous calls and state.

In App.jsx add:

import { useUssrEffect } from '@rockpack/ussr';

Replace useEffect with useUssrEffect:

useUssrEffect(async () => {
const data = await asyncFn();
setState(data);
});

Let's make changes to client.jsx:

import createUssr from '@rockpack/ussr';
const [Ussr] = createUssr();
hydrate(
<Ussr>
<App />
</Ussr>,
document.getElementById('root')
);

In server.jsx, replace the standard renderToString with:

import { serverRender } from '@rockpack/ussr';
...
const { html } = await serverRender(() => <App />);

If you run the application now, nothing will happen! We will not see the result of executing the async functionasyncFn. Why is this happening? We forgot to sync state. Let's fix this.

In App.jsx, replace the standard setState with:

import { useUssrState, useUssrEffect } from '@rockpack/ussr';
...
const [state, setState] = useUssrState({ text: 'text here' });

It allows you to take the state from the cache sent from the server.

Let's replace in client.jsx:

const [Ussr] = createUssr(window.USSR_DATA);

window.USSR_DATA is an object passed from the server with a cached state for front-end synchronization.

server.jsx:

Add the module:

import serialize from 'serialize-javascript';

And replace:

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

As we can see, serverRender returns state in addition to html. We cache it in the HTML template, in the field

<script>
window.USSR_DATA = ${serialize(state, { isJSON: true })}
</script>

This way, the state will be populated on the server and passed to the client as a regular object, where it will be used by the setUssrState method and returned as the default value.

That's it! We now have an SSR-enabled application. For a more realistic example, where not setState will be used, but Redux, please read the continuation.

Complete code

App.jsx:

import React from 'react';
import { useUssrState, useUssrEffect } from '@rockpack/ussr';
const asyncFn = () => new Promise((resolve) => setTimeout(() => resolve({ text: 'Hello world' }), 1000));
export const App = () => {
const [state, setState] = useUssrState({ text: 'text here' });
useUssrEffect(async () => {
const data = await asyncFn();
setState(data);
});
return (
<div>
<h1>{state.text}</h1>
</div>
);
};

client.jsx:

import React from 'react';
import { hydrate } from 'react-dom';
import createUssr from '@rockpack/ussr';
import { App } from './App';
const [Ussr] = createUssr(window.USSR_DATA);
hydrate(
<Ussr>
<App />
</Ussr>,
document.getElementById('root')
);

server.jsx:

import React from 'react';
import express from 'express';
import { serverRender } from '@rockpack/ussr';
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.USSR_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!');
});

Also the code is available here

License MIT, 2020