React Server Side Rendering (without a Server)
This is the second part of my previous post “Create a Progressive Web App with React”.
Too many weeks and things have gone by since that post (including a standoff between the Apache Foundation and Facebook due React.js open source licensing) but I wanted to retake a specific topic of PWAs: Server Side Rendering.
During our previous post we achieved a good score in Lighthouse by enabling offline access and adding a few meta tags to our sample React app. However, we still have a big issue: Our React app takes 8450 ms long to start. What is happening here?
If you’re familiar with Single Page Applications, you’ll know that most of them are loaded using JavaScript. This means that, until those JavaScript files are downloaded, parsed and executed, all you will be able to see is the original content of the base HTML document.
Right click the application, click on “View Page Source” and you will see the actual elements that are visible while you load your JS files:
So, effectively we have an empty page. In a slow mobile connection, your users will see this elements for a while (8,450 ms, according to Lighthouse); which will drive them away.
How do we fix this? We have to make sure the original HTML returns at least the most basic content of our page; something for our users to see in order to keep them busy while we load our JS applications. The most important content of our page, preferably. Some people call this Critical Rendering Path.
Now, since all the elements in our application are generated by React, how can we show this content before even React is loaded?
A simple solution might be to add content to our DIV element with the “app” id. Let’s try something like this:
<div id="app">
<h1>Loading</h1>
We are loading your tacos
</div>
Let’s see if that helped:
Nope, Lighthouse is smarter than we thought. It doesn’t consider our little loading message as “meaningful” content. What else can we do?
Server Side Rendering is the most common solution for this problem. It basically relies on the backend server to pre-calculate our application’s initial state and components, and return it in the base HTML.
In our case, there is a react-dom/server module for NodeJS which does the same thing React does in the browser, but on the backend: Create the components and return the parsed HTML in the response of the main document.
However, we have a huge problem. We’re all Serverless. We don’t have a Server Side to do Server Side Rendering. Are we out of options?
Webpack plugins
Webpack process our source code and is already taking care of generating our index.html. We can hook into Webpack using plugins. We can take advantage of these plugins to pre-process our React app during Webpack’s build process, and generate an index.html (which, in our example is created by HtmlWebpackPlugin) which will have the same elements react-dom/server would return in regular Server Side rendering.
In a high level, we will execute our React app in a virtual DOM during Webpacks execution, which will generate the elements that will be displayed in our application and load initial data. Then, we will inject that parsed HTML into our static index.html.
Besides Webpack, we will take advantage of two tools to get to this result: Node’s Express and JSDOM.
Even when this configuration is focused on Webpack, you could use other build tools like Gulp or Grunt, as long as you follow the same concepts exposed here
First, make sure you install Express and JSDOM into the project as dev dependencies. Then, create the following file in src/webpack/plugin (the location doesn’t really matter, in this case is just to make the configuration work out of the box):
This plugin does the following tasks:
- We create an Express server which will point to our dist folder. JSDOM will use this server to resolve our static resources, specifically the built JavaScript resources. We also add the folder json, since that’s were we are getting our data from (data.json). In a real app, you might want to avoid this include in the Express server and point data.json in line 48 to a real REST endpoint.
- In line 33, we plug into Webpack’s build process to retrieve the HTML coming out of HtmlWebpackPlugin.
- In line 40, we create a virtual DOM with JSDOM, and we point it to our Express server. JSDOM will parse index.html’s content generated by the HtmlWebpackPlugin, and resolve all JS and CSS links from the Express server.
- In line 50, we add a listener which will be executed once the virtual DOM finishes loading. From there, we can manipulate it like a regular DOM.
- In line 53, we make a request to data.json, which is the data that loads on the page, and we insert it to our virtual DOM in line 62. This will make sure that the application has access to the initial load of data without making an extra HTTP request on the variable __PRELOADED_STATE__.
- In lines 65 and 66, we take our processed HTML (which already has the initialized React app) and put it back in Webpack’s build process. This will end up in index.html (this step also will allow Webpack to generate the HTML correctly using the Dev server).
Add the following to webpack.config.js, after HtmlWebpackPlugin:
new ReactServerHTMLPlugin({}),
If you build the project, and check the index.html inside the dist folder, you will notice that our application is now in there:
<html lang="en"><head>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<title>Taco Galery</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#999999">
<link rel="shortcut icon" href="favicon.ico"><script>window.__PRELOADED_STATE__ = "[\r\n{\r\n\t\"id\":1,\r\n\t\"name\":\"Al pastor\",\r\n\t\"price\":\"2.45\",\r\n\t\"description\": \"Mexico style pork tacos\"\r\n},\r\n{\r\n\t\"id\":2,\r\n\t\"name\":\"Beef fajita\",\r\n\t\"price\":\"3.45\",\r\n\t\"description\": \"Beef fajita tacos, with onion and cilantro\"\r\n},\r\n{\r\n\t\"id\":3,\r\n\t\"name\":\"Fish tacos\",\r\n\t\"price\":\"3.45\",\r\n\t\"description\": \"Fresh fish with onions, letuce and carrots\"\r\n}\r\n]"</script></head>
<body>
<div id="app"><section data-reactroot="" class="container"><h1>Today tacos</h1><div><div><h2>Al pastor</h2><span><!-- react-text: 7 -->Price: $<!-- /react-text --><!-- react-text: 8 -->2.45<!-- /react-text --></span><p>Mexico style pork tacos</p></div><div><h2>Beef fajita</h2><span><!-- react-text: 13 -->Price: $<!-- /react-text --><!-- react-text: 14 -->3.45<!-- /react-text --></span><p>Beef fajita tacos, with onion and cilantro</p></div><div><h2>Fish tacos</h2><span><!-- react-text: 19 -->Price: $<!-- /react-text --><!-- react-text: 20 -->3.45<!-- /react-text --></span><p>Fresh fish with onions, letuce and carrots</p></div></div></section></div>
<link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
<script type="text/javascript" src="vendor.bundle.js"></script><script type="text/javascript" src="main.js"></script>
</body></html>
Now, we need to change our application to first read the initial data loaded in __PRELOADED_STATE__. Change the following:
class Main extends React.Component {
constructor(props) {
super(props);
this.state = {
tacos:[]
};
}
...
To this:
class Main extends React.Component {
constructor(props) {
super(props);
var data = JSON.parse(window.__PRELOADED_STATE__) ;
this.state = data || {
tacos:[]
};
}
In that way, if __PRELOADED_STATE__ exists, we will use it’s value as the initial data for our app. If it doesn’t, it will start with an empty JSON.
Load your app, and run Lighthouse:
Our initial load went from 8,450 ms to 2,340 ms. Now Lighthouse is happier!
There is a big downside to this approach. It assumes that the contents of data.json will be the same when the page loads for the user than when you build your project. Most of the time, this assumption will be false.
That is why you have to be smart about what is your Critical Rendering Path, and use in data.json only data that won’t change, or use an endpoint which will return empty data. Otherwise, the user will see an older version of the data as the page starts loading, and the newest version as it fully loads.
Having no server behind our UI makes creating PWAs a more complicated process. We cannot rely in regular Server Side Rendering techniques, but we can start web servers and virtual DOMs to pre-process our app and create HTML files which will contain all the elements of a fully loaded SPA.