DEV Community

Vivek Kumar
Vivek Kumar

Posted on

React Server Side Rendering Setup -2024

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
Enter fullscreen mode Exit fullscreen mode

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");
})

Enter fullscreen mode Exit fullscreen mode

Now, if you try to run below command
node src/server/index.js
Output:

Server is running on port:  3000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
                }
            }
        ]
    },
}
Enter fullscreen mode Exit fullscreen mode

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"}]
    ]
}
Enter fullscreen mode Exit fullscreen mode

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",

  },
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
})
Enter fullscreen mode Exit fullscreen mode

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'
                }
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

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) => {
....
Enter fullscreen mode Exit fullscreen mode

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 />
);

Enter fullscreen mode Exit fullscreen mode

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>
....
Enter fullscreen mode Exit fullscreen mode

And on Client side, update as given below:

...
import { BrowserRouter } from 'react-router-dom';
...
...
const domNode = document.getElementById('root');

hydrateRoot(domNode, 
<BrowserRouter>
    <App />
</BrowserRouter>

);
...

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
  },
Enter fullscreen mode Exit fullscreen mode

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)