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