Server-Side Rendering with React from Scratch

Hi there,

My name is Igar Ramaddhan and I am an engineer at kumparan.com. It's a long journey for me to get at this point. I've learn React for 2 years and it's so amazing. I'm going to share my knowledge on how to build server-side rendered React app.

Under the hood

First of all we need to know how React works. There're two types of DOM (Document Object Model) or in common we know it's your HTML. One is real DOM and the other one is virtual DOM. In the real DOM, every time changes happen the DOM got updated and it is so slow when it happens frequently. The changes make the element and its children need to be re-rendered. The rendering is what makes it slow and the more component you have, the more element needs to be updated.

Virtual DOM

React is using virtual DOM where it's only the represent the real DOM. Every time changes happen, the virtual DOM gets updated. Why React is using it? It's because the virtual DOM is faster and efficient. When a change occurred, the virtual DOM compare the current change with it's previous and calculate the best possible way to make this change on the DOM. This makes the real DOM done a minimal operation and made it faster.

Client-Side Rendering

So basically React is using what's called client-side rendering. It's just a single HTML without any content and you're waiting until the Javascript gets downloaded. After that your browser will do the rendering. This might be good if you have a reliable internet connection. What if you have a sucks damn slow internet connection? You probably can take the housekeeping while waiting for it. There's a solution for that and it's server-side rendering.

Server-Side Rendering

The old method of server-side rendering is just like your server compile everything and deliver a very populated HTML page to the client. But every time you navigate to the other page, the server had to do the work again. It's heavy for the server.

The new method of server-side rendering is doing that kind of job on the server only for the first page you're accessing, and it will do the dynamic routing for you on the client-side (your browser). Sounds cool right and we're going to make it in this tutorial šŸ˜.

Let's go to the code....

First, make sure you have NPM installed on your machine. I'm using yarn here, it's just preference.

We start on making a new folder and run yarn init -y. After that, you need to add this into your package.json

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "start": "webpack -w & nodemon server.js"
},
"devDependencies": {
  "nodemon": "^2.0.4",
  "source-map-loader": "^0.2.4",
  "ts-loader": "^7.0.5",
  "typescript": "^3.9.3",
  "webpack": "^4.43.0",
  "webpack-cli": "^3.3.11",
  "webpack-node-externals": "^1.7.2"
},
"dependencies": {
  "@types/cors": "^2.8.6",
  "@types/express": "^4.17.6",
  "@types/isomorphic-fetch": "^0.0.35",
  "@types/react": "^16.9.35",
  "@types/react-dom": "^16.9.8",
  "cors": "^2.8.5",
  "express": "^4.17.1",
  "isomorphic-fetch": "^2.2.1",
  "react": "^16.13.1",
  "react-dom": "^16.13.1"
}

Save it and run yarn. After that you'll need to make tsconfig.json file:

{
  "compilerOptions": {
    "sourceMap": true,
    "noImplicitAny": false,
    "module": "commonjs",
    "target": "es6",
    "lib": [
      "es2015",
      "es2017",
      "dom"
    ],
    "removeComments": true,
    "allowSyntheticDefaultImports": false,
    "jsx": "react",
    "baseUrl": "./",
    "paths": {
      "components/*": [
        "src/components/*"
      ],
    },
  },
  "exclude": [ "node_modules", "dist" ]
}

then you'll have to make webpack.config.js

const path = require('path');
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');

function commonConfig(isBrowser) {
  return {
    mode: 'development',
    devtool: 'source-map',
    resolve: {
      extensions: [".ts", ".tsx", ".js", ".jsx"]
    },
    module: {
      rules: [
        {
          test: /\.ts(x?)$/,
          exclude: /node_modules/,
          use: [
            {
              loader: 'ts-loader',
            },
          ],
        },
        {
          enforce: 'pre',
          test: /\.js$/,
          loader: 'source-map-loader',
        },
      ],
    },
    plugins: [new webpack.DefinePlugin({__isBrowser__: isBrowser})],
  };
}

const browserConfig = {
  entry: path.resolve(__dirname, 'src/client.tsx'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
    publicPath: '/',
  },
  ...commonConfig(true),
};

const serverConfig = {
  entry: path.resolve(__dirname, 'src/server.tsx'),
  target: 'node',
  externals: [nodeExternals()],
  output: {
    path: __dirname,
    filename: 'server.js',
    publicPath: '/',
  },
  ...commonConfig(false),
};

module.exports = [browserConfig, serverConfig];

then, you need to make src folder containing server.tsx, client.tsx, and App.tsx. So far your project will look like this:

.
ā”œā”€ā”€ node_modules
ā”œā”€ā”€ package.json
ā”œā”€ā”€ src
ā”‚   ā”œā”€ā”€ App.tsx
ā”‚   ā”œā”€ā”€ client.tsx
ā”‚   ā””ā”€ā”€ server.tsx
ā”œā”€ā”€ tsconfig.json
ā”œā”€ā”€ webpack.config.js
ā””ā”€ā”€ yarn.lock

Write this into your server.tsx file:

import * as React from 'react';
import * as express from 'express';
import * as cors from 'cors';
import {renderToString} from 'react-dom/server';
import App from './App';

const app = express();

app.use(cors());

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

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

  const html = `
  <!DOCTYPE html>
  <html>
    <head>
      <title>React SSR</title>
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1, shrink-to-fit=no"
      />
    </head>
    <body>
      <div id="app">${markup}</div>
    </body>
  </html>
  `;

  res.send(html);
});

app.listen(3000, () => console.log('Server is running on port 3000'));

and this too in your App.tsx:

import * as React from 'react'

const App = () => {
  return (
    <div>Hello World</div>
  )
}

export default App;

At this point, you can just run your app and check it on localhost:3000

yarn start

you'll see something like this:

Screen Shot 2020-05-26 at 00.47.43.png

Okay, you've done making your app server-rendered. Now we're going to take care of the client-side and make it downloaded the bundled file to run on the client.

First, you need to make a change to your server.tsx file:

...
    <head>
      <title>React SSR</title>
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1, shrink-to-fit=no"
      />

      // add this
      <script src="/bundle.js" defer></script>
    </head>
...

then write this on your client.tsx file:

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';

ReactDOM.hydrate(<App />, document.getElementById('app'));

So what is it? It will tell React that you've already rendered the app on the server and instead of re-render it on the client it just needs to attach what's needed on the existing app. In the Network section, you'll see that your browser downloaded the bundle.js file:

Screen Shot 2020-05-26 at 00.49.01.png

Yeay.. we're done... but... there's one thing that you need to remember. The data on the server must be the same as in the client. Let's take a look at the App.tsx file and add name props to it.

import * as React from 'react'

const App = (props: {name: string}) => {
  return (
    <div>Hello {props.name}</div>
  )
}

export default App;

then make sure that you add the props in your server.tsx and client.tsx file

server.tsx

...

app.get('*', (req, res, next) => {
  const markup = renderToString(<App name="Rama" />);

...

client.tsx

...

ReactDOM.hydrate(<App name="Rama" />, document.getElementById('app'));

see it on your browser and it will look like this

Screen Shot 2020-05-26 at 01.14.29.png

now let's change the name props on the client-side

...

ReactDOM.hydrate(<App name="John" />, document.getElementById('app'));

then refresh your browser and watch it carefully. It will show you that the name is blinking from Rama into John and you'll see something like this on the console section

Screen Shot 2020-05-26 at 01.20.06.png

This is why you need to provide identical data rendered on the server and the client. The app is still running for now, but if something like this happened on the more complex app it can cause the app to break.

So how to fix this? It's easy, we just need an old-school way but it works pretty well. JUST THROW IT TO THE WINDOW šŸ¤£.

First, we need to add serialize-javascript to our project

yarn add serialize-javascript

then, change our server.tsx file

...

import * as serialize from 'serialize-javascript';

...

app.get('*', (req, res, next) => {
  const name = "Rama";
  const markup = renderToString(<App name={name} />);

  const html = `
  <!DOCTYPE html>
  <html>
    <head>
      <title>React SSR</title>
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1, shrink-to-fit=no"
      />

      <script src="/bundle.js" defer></script>
      <script>window.__INITIAL_DATA__ = ${serialize(name)}</script>
    </head>
    <body>
      <div id="app">${markup}</div>
    </body>
  </html>
  `;

  res.send(html);
});

...

and we need to change the client.tsx file also

...

ReactDOM.hydrate(<App name={window['__INITIAL_DATA__']} />, document.getElementById('app'));

Refresh your browser and tada... šŸŽ‰ no more error

Screen Shot 2020-05-26 at 01.36.23.png

That's all, happy practicing!

This is my first writing, any feedback is welcomed šŸ˜†

In the next tutorial, we'll configure the navigation on this app šŸ˜

See you šŸ˜˜

No Comments Yet