In this tutorial we are going to setup development environment for React Server Side Rendering.
We are going to use React v18, Webpack v5, React Router DOM v6 and Express v5.
Step 1: Creating directory structure
Create a project folder, I will name it 'react-ssr'.
Initialize project with
npm init --yes
Create a folder structure like this:
src
-client
--index.js
-server
--index.js
package.json
Step 2: Create a basic express server
If you have not used express js, that is fine, I will try to explain everything I will write here.
Express js is node js framework, people mostly use it to create APIs.
To get started, lets install express
npm i -S express
To create a basic server, write below code in 'server/index.js'
import express from 'express';
const app = express();
app.get('/', (req, res) => {
res.send('Hello React SSR');
});
app.listen(3000, () => {
console.log("Server is running on port: 3000");
})
Now, if you try to run below command
node src/server/index.js
Output:
Server is running on port: 3000
If your import doesn't work then add "type":"module" in package.json
If you try to access localhost:3000, you will see 'Hello React SSR'
Step 3: Using webpack to bundle server code
For production, we need to bundle our code and transform it to standard javascript code. So we will write webpack configuration for server.
Create a file "webpack/server.config.js"
src
webpack
-server.config.js
package.json
In server.config.js write below code:
const path = require('path');
const externals = require('webpack-node-externals');
module.exports = {
target: 'node', // Since target is node not browser
entry: "./src/server/index.js",
mode: 'production',
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'server.js'
},
resolve: {
extensions: ['.js', '.jsx']
},
externals: [externals()], // will not bundle node modules
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
}
]
},
}
lets install dependencies for webpack
npm i -D webpack webpack-cli babel-loader webpack-node-externals
Although we have a loader called 'babel-loader' for webpack but this will not work until we configure babel.
For the basic setup of babel, create a file with name .babelrc
and write following content:
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime" : "automatic"}]
]
}
Lets install babel now.
npm i -D @babel/core @babel/preset-env @babel/preset-react
Step 4: Script to bundle server code
In package.json, write following command:
"scripts": {
"build:server": "webpack --config webpack/server.config.js",
},
We are explicitly giving path of config, if we don't do that it will try to pick webpack.config.js from root directory and we don't have that.
if you run "npm run build:server"
it will create a dist folder in root directory and if you open that folder, you will find we have one file i.e. server.js with some minified code.
And that minification is happening because we have given "mode " as production in webpack config.
Step 5: Creating our first React Component
Create a file App.jsx in 'src/client/App.jsx'
const App = () => {
return (
<>
This is our App component
<>
)
}
export default App;
Step 6: Update server code to return html string
Now we will be updating our server/index.js file as shown below:
import express from 'express';
import { renderToString } from 'react-dom/server';
import App from '../client/App';
const app = express();
//* - it will match every request
app.get('*', (req, res) => {
const html = renderToString(
<App />
);
res.send(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SSR React App</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`
)
})
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log('Server is running on port: ', port);
})
lets install react dependencies:
npm i -D react react-dom
So here we are using renderToString, this will transform component to string.
If you have noticed, we are using bundle.js, which we don't have currently. bundle.js is the bundle file for the client, which we are going to put it in public folder and server it from there.
Its ok if you didn't understand, this will be clear in next few steps:
Step 7: Webpack config for Client code
create a file webpack/client.config.js
const path = require('path');
module.exports = {
entry: "./src/client/index.js",
mode: "production",
output: {
path: path.resolve(__dirname, '../public'),
filename: "bundle.js"
},
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
}
]
}
}
if you noticed, this will create bundle.js inside public folder.
So, lets create a public folder in root directory.
Also, on server I want to add a script while sending html string to client as shown below:
but how this bundle.js be available on /bundle.js.
For that we will have to make 'public' folder as static folder for express.
const app = express();
app.use(express.static('public'));
app.get('*', (req, res) => {
....
Step 8: Client hydration code
In file src/client/index.js
Write below lines:
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const domNode = document.getElementById('root');
hydrateRoot(domNode,
<App />
);
How this hydration works?
On initial request, server will send rendered html string, and in html string we will also send a script that will have a link of client side bundle. Once we open the response in browser, bundle.js code will execute hyration code, that will compare the root component and will to attach all the user interaction code such as even listener or hooks. From here on usually we will be using react router dom which actually doesn't refresh page, so all the navigation will be part of client side bundle.
Every time we refresh our page, a request goes to server and that then server will do same thing as mentioned above
Now, lets say we have a route on client something called
localhost:3000/orders
For first time if we open this url, request will be sent to server, since we are trying to hit server's endpoint, server will do all the process irrespective of what are adding at the end of url, here '/orders'.
To use this endpoint and forward it to client, we will use StaticRouter.
Step 9: Setting up react router dom
npm i -D react-router-dom
Update our server code as given below:
import { StaticRouter } from 'react-router-dom/server';
...
...
app.get('*', (req, res) => {
const html = renderToString(
<StaticRouter location={req.url} >
<App />
</StaticRouter>
....
And on Client side, update as given below:
...
import { BrowserRouter } from 'react-router-dom';
...
...
const domNode = document.getElementById('root');
hydrateRoot(domNode,
<BrowserRouter>
<App />
</BrowserRouter>
);
...
In App.js we can add few routes for some pages.
import { Route, Routes } from "react-router-dom";
import Orders from "./pages/Orders";
import Products from "./pages/Products";
const App = () => {
return (
<Routes>
<Route path="/" element={<Orders />} />
<Route path="/products" element={<Products />} />
</Routes>
)
}
export default App;
Step 10: Update package json script
"scripts": {
"build:client": "webpack --config webpack/client.config.js",
"build:server": "webpack --config webpack/server.config.js",
"build": "npm run build:client && npm run build:server",
"start:dev": "concurrently \"npm run build:server && nodemon --exec babel-node src/server/index.js\" \"npm run build:client -- --watch\"",
"start": "npm run start:dev"
},
Install nodemon and concurretly to keep watching changes and run multiple commands together.
npm i -D nodemon concurrently
Try this out and comment out the problem you face.
Instead of using renderToString, you can also use react streaming server api - renderToPipeableStream
If you use renderToPipeableStream, then you will be able to lazy load things on UI.
Top comments (0)