A Developer's Ultimate Handbook to Server-Side Rendering in React JS

Aniket Ashtikar

Blog / A Developer's Ultimate Handbook to Server-Side Ren

Modern web development keeps evolving and Server Side Rendering (SSR) in React JS is one of the many important techniques.

At the core of it, SSR is about rendering components on the server and sending completely rendered HTML to the client instead of an empty HTML file and then waiting for client-side JavaScript to fill the gap.

On the other hand, CSR sends a minimal HTML file to the browser along with JavaScript bundles and renders the content in the client's browser. Though it's been the go-to approach for so many React applications, in some scenarios, SSR does offer a lot of differences.

It might seem overwhelming right now but I will guide you through the process in a simpler, comprehensive way.

The relevance of SSR in modern web development cannot be overstated. SSR addresses these concerns head-on, making it an essential tool in a React developer's toolkit .
- Aniket Ashtikar, Technology Architect

This exhaustive tutorial will cover everything regarding server side rendering in React JS, from the conceptualization to the advanced implementation techniques and optimization methods.

Understanding Server Side Rendering in React JS

How SSR works in React

In a React SSR setup, the process of rendering happens in the following steps:

Step 1: A request for a page is received by the server.
Step 2: It creates a fresh instance of a React application.
Step 3: The server renders the components to HTML strings.
Step 4: This pre-rendered HTML is sent to the client along with the necessary JavaScript.
Step 5. The browser on the client's machine displays the HTML immediately, and it does so very quickly.
Step 6: React then "hydrates" the HTML, adding event listeners to make it interactive.

Here's a simple diagram to illustrate this process:

Image: Server-side Rendering and Hydration Process

This approach combines the best of both worlds: fast initial page loads and fully interactive applications.

Key differences between SSR and CSR

  1. Initial Load: With SSR, the initial page load happens much faster because the HTML has been pre-rendered. The users see content almost instantly, even before JavaScript loads.
     
  2. SEO: SSR is far more SEO-friendly because search engines can easily crawl this fully rendered content. It is a specific necessity for the content-rich websites or for sites relying highly on organic search traffic.
     
  3. Performance: SSR performs better at low powered devices because very less client-side processing is required. This would lead to better user experience of the users over a wider range of devices.
     
  4. Complexity:  SSR usually involves more complex setup and maintenance. Developers must take into account both server and client environments while writing code.
  1. Server Load: SSR can create a higher load for servers since rendering is conducted on the server side for every request.

The role of Node.js in React SSR

Node.js plays an important role in the React SSR because it provides us with a JavaScript runtime on the server. This allows execution of the React code on the server; this is very important because of SSR.

Some of the key features of Node.js in SSR are:

  1. JavaScript Everywhere: Node.js enables both client and server to run JavaScript so that developers are not context switching.
     
  2. Efficient I/O: Node.js's event-driven, non-blocking I/O model makes it well-suited for handling SSR requests efficiently.
     
  3. npm Ecosystem: The vast npm ecosystem provides numerous tools and libraries to facilitate SSR implementation.
     
  4. Performance: Node.js's V8 engine offers high-performance JavaScript execution, crucial for rendering React components quickly on the server.

Understanding these fundamental concepts of SSR in React JS sets the stage for diving deeper into its benefits, challenges, and implementation strategies.

Benefits and Challenges of SSR in React

Benefits

  1. Speedier initial page load
    SSR dramatically cuts the FCP time . Since pre-rendered HTML is sent to the client, the user gets content nearly instantly, almost before JavaScript has even loaded leading to a perceived increase in performance.
  2. Improved Time to Interactive (TTI)
    While full interactivity takes a bit longer with SSR, the first content is rendered way quicker. Thus, the user can start reading or viewing content with actual content loading in the background while still loading the JavaScript, speeding up the overall user experience .
  3. Reduced Client-Side Processing
    SSR offloads the rendering of the initial work to the server, which reduces the burden that is put on the client's processing device. This helps benefit users with less powerful devices or older smartphones so they can have a more uniform experience across different hardware capabilities.
  4. Enhanced Search Engine Optimization (SEO)
    Server rendering is easy for search engines to crawl and index, meaning that your site will rank higher in search results.
  5. Improved Performance for Static Content
    It's possible to use SSR for content that is not often changed, along with static site generation techniques . This can provide the benefits of two worlds: dynamic capabilities and performance of static sites.
  6. Improved accessibility
    SSR can improve accessibility by providing content even when JavaScript fails to load or execute. This ensures that users with assistive technologies can access your content more reliably.
  7. Consistency on any device
    SSR balances the playing field to provide a more consistent experience no matter what device the user makes use of.

Challenges

  1. Increased complexity of development: 
    SSR requires state and routing management in both server and client for coherent output in rendering. It does require a much deeper knowledge of both environments.
     
  2. Higher Load on Server: 
    Server-side rendering can increase the usage of CPU and memory, making it potentially costly to host and scale.
     
  3. Hydration issues and state management: 
    The hydration of server-side rendered HTML on the client-side can sometimes be tricky, particularly when involving complex state management and fetching of async data.
     
  1. Longer time to full interactivity: 
    While SSR provides faster initial content visibility, achieving full interactivity may take longer because the client needs to download and execute JavaScript.
     
  2. Third-party library compatibility: 
    Some client-side libraries might not work out of the box with SSR, requiring additional configuration or server-side alternatives.

Setting up a basic React SSR project

1. Required dependencies
To get started with SSR in React, you'll need to install the following dependencies:

  • react and react-dom: Core React libraries
  • express: Web server framework for Node.js
  • webpack and webpack-cli: For bundling our application
  • babel-loader and related packages: For transpiling JSX and modern JavaScript
  • nodemon: For automatically restarting the server during development
  • webpack-node-externals: To avoid bundling Node.js built-in modules
npm install react react-dom express webpack webpack-cli babel-loader @babel/core @babel/preset-env @babel/preset-react nodemon webpack-node-externals

2. Project structure

  A typical SSR React project structure might look like this:

  /src
    /client
      index.js
      App.js
    /server
      server.js
    /shared
      App.js
  webpack.client.js
  webpack.server.js
  package.json

This structure separates client-side, server-side, and shared code, making it easier to manage the complexity of an SSR application.

Creating a server-side entry point

The server-side entry point is responsible for rendering the React application on the server and sending it to the client. Here's a basic example:

// src/server/server.js
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from '../shared/App';
const app = express();
const port = 3000;
app.use(express.static('public'));
app.get('*', (req, res) => {
 const app = ReactDOMServer.renderToString(<App />);
 
 const html = `
   <!DOCTYPE html>
   <html>
     <head>
       <title>React SSR</title>
     </head>
     <body>
       <div id="root">${app}</div>
       <script src="/client.js"></script>
     </body>
   </html>
 `;
 
 res.send(html);
});
app.listen(port, () => {
 console.log(`Server is listening on port ${port}`);
});

This server renders the App component to a string using ReactDOMServer.renderToString and injects it into an HTML template. The resulting HTML is then sent to the client.

Configuring webpack for SSR

We need two webpack configurations: one for the client and one for the server.

  1. Client-side webpack configuration (webpack.client.js):
const path = require('path');
module.exports = {
 entry: './src/client/index.js',
 output: {
   filename: 'client.js',
   path: path.resolve(__dirname, 'public'),
 },
 module: {
   rules: [
     {
       test: /\.js$/,
       exclude: /node_modules/,
       use: {
         loader: 'babel-loader',
         options: {
           presets: ['@babel/preset-env', '@babel/preset-react']
         }
       }
     }
   ]
 }
};

2. Server-side webpack configuration (webpack.server.js):

const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
 target: 'node',
 entry: './src/server/server.js',
 output: {
   filename: 'server.js',
   path: path.resolve(__dirname, 'build'),
 },
 module: {
   rules: [
     {
       test: /\.js$/,
       exclude: /node_modules/,
       use: {
         loader: 'babel-loader',
         options: {
           presets: ['@babel/preset-env', '@babel/preset-react']
         }
       }
     }
   ]
 },
 externals: [nodeExternals()]
};

Implementing server-side routing

For server-side routing, you can use a library like React Router. Here's how you might implement it:

// src/server/server.js
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from '../shared/App';
const app = express();
app.get('*', (req, res) => {
 const context = {};
 const app = ReactDOMServer.renderToString(
   <StaticRouter location={req.url} context={context}>
     <App />
   </StaticRouter>
 );
 
 // ... rest of the server code
});

On the client side, you would use BrowserRouter:

// src/client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from '../shared/App';
ReactDOM.hydrate(
 <BrowserRouter>
   <App />
 </BrowserRouter>,
 document.getElementById('root')
);

Handling state management with SSR (e.g., Redux)

When using Redux with SSR, you need to create a store on the server for each request and pass the initial state to the client. Here's a basic implementation:

// src/server/server.js
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from '../shared/reducers';
app.get('*', (req, res) => {
 const store = createStore(rootReducer);
 
 const app = ReactDOMServer.renderToString(
   <Provider store={store}>
     <StaticRouter location={req.url} context={{}}>
       <App />
     </StaticRouter>
   </Provider>
 );
 const preloadedState = store.getState();
 const html = `
   <!DOCTYPE html>
   <html>
     <head>
       <title>React SSR with Redux</title>
     </head>
     <body>
       <div id="root">${app}</div>
       <script>
         window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}
       </script>
       <script src="/client.js"></script>
     </body>
   </html>
 `;
 res.send(html);
});

On the client side, you would use the preloaded state to initialize your store:

// src/client/index.js
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from '../shared/reducers';
const preloadedState = window.__PRELOADED_STATE__;
delete window.__PRELOADED_STATE__;
const store = createStore(rootReducer, preloadedState);
ReactDOM.hydrate(
 <Provider store={store}>
   <BrowserRouter>
     <App />
   </BrowserRouter>
 </Provider>,
 document.getElementById('root')
);

Basic SSR implementation in React

Here's a complete example bringing all the pieces together:

// src/shared/App.js
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { connect } from 'react-redux';
const Home = () => <h1>Welcome to the Home Page</h1>;
const About = () => <h1>About Us</h1>;
const Counter = ({ count, increment, decrement }) => (
 <div>
   <h2>Counter: {count}</h2>
   <button onClick={increment}>Increment</button>
   <button onClick={decrement}>Decrement</button>
 </div>
);
const mapStateToProps = (state) => ({
 count: state.count
});
const mapDispatchToProps = (dispatch) => ({
 increment: () => dispatch({ type: 'INCREMENT' }),
 decrement: () => dispatch({ type: 'DECREMENT' })
});
const ConnectedCounter = connect(mapStateToProps, mapDispatchToProps)(Counter);
const App = () => (
 <div>
   <nav>
     <ul>
       <li><a href="/">Home</a></li>
       <li><a href="/about">About</a></li>
       <li><a href="/counter">Counter</a></li>
     </ul>
   </nav>
   <Switch>
     <Route exact path="/" component={Home} />
     <Route path="/about" component={About} />
     <Route path="/counter" component={ConnectedCounter} />
   </Switch>
 </div>
);
export default App;
// src/shared/reducers.js
const initialState = {
 count: 0
};
const rootReducer = (state = initialState, action) => {
 switch (action.type) {
   case 'INCREMENT':
     return { ...state, count: state.count + 1 };
   case 'DECREMENT':
     return { ...state, count: state.count - 1 };
   default:
     return state;
 }
};
export default rootReducer;
// src/server/server.js
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import App from '../shared/App';
import rootReducer from '../shared/reducers';
const app = express();
const port = 3000;
app.use(express.static('public'));
app.get('*', (req, res) => {
 const store = createStore(rootReducer);
 const context = {};
 
 const appContent = ReactDOMServer.renderToString(
   <Provider store={store}>
     <StaticRouter location={req.url} context={context}>
       <App />
     </StaticRouter>
   </Provider>
 );
 const preloadedState = store.getState();
 const html = `
   <!DOCTYPE html>
   <html>
     <head>
       <title>React SSR with Redux and React Router</title>
       <script src="/client.js" defer></script>
     </head>
     <body>
       <div id="root">${appContent}</div>
       <script>
         window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}
       </script>
     </body>
   </html>
 `;
 res.send(html);
});
app.listen(port, () => {
 console.log(`Server is listening on port ${port}`);
});
// src/client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import App from '../shared/App';
import rootReducer from '../shared/reducers';
const preloadedState = window.__PRELOADED_STATE__;
delete window.__PRELOADED_STATE__;
const store = createStore(rootReducer, preloadedState);
ReactDOM.hydrate(
 <Provider store={store}>
   <BrowserRouter>
     <App />
   </BrowserRouter>
 </Provider>,
 document.getElementById('root')
);
// webpack.client.js
const path = require('path');
module.exports = {
 entry: './src/client/index.js',
 output: {
   filename: 'client.js',
   path: path.resolve(__dirname, 'public'),
 },
 module: {
   rules: [
     {
       test: /\.js$/,
       exclude: /node_modules/,
       use: {
         loader: 'babel-loader',
         options: {
           presets: ['@babel/preset-env', '@babel/preset-react']
         }
       }
     }
   ]
 }
};
// webpack.server.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
 target: 'node',
 entry: './src/server/server.js',
 output: {
   filename: 'server.js',
   path: path.resolve(__dirname, 'build'),
 },
 module: {
   rules: [
     {
       test: /\.js$/,
       exclude: /node_modules/,
       use: {
         loader: 'babel-loader',
         options: {
           presets: ['@babel/preset-env', '@babel/preset-react']
         }
       }
     }
   ]
 },
 externals: [nodeExternals()]
};
// package.json (scripts section)
{
 "scripts": {
   "build:client": "webpack --config webpack.client.js",
   "build:server": "webpack --config webpack.server.js",
   "build": "npm run build:client && npm run build:server",
   "start": "node build/server.js",
   "dev": "npm run build && npm run start"
 }
}

This example demonstrates a basic SSR setup with React, Redux, and React Router. It shows how to render the initial state on the server, pass it to the client, and hydrate the application on the client-side.

SSR Frameworks and Tools for React

Next.js: The popular SSR framework for React

Next.js is the default framework of many developers to implement SSR on their React applications. It offers a powerful set of features that simplify the process of SSR.

  1. Overview and benefits
    1. Automatic code splitting: Your code will be automatically split into smaller chunks to reduce load times.
    2. File-based routing: Create routes with minimal hassle by simply adding files into the `pages` directory.
    3. API routes: Easily create API endpoints as part of your Next.js app.
    4. Static site generation (SSG): Static pages are generated at build time to achieve the maximum speed
    5. Image optimization: Automatic image compression and resizing
    6. Zero config: Out-of-the box, with sane defaults.
  2. Basic Next.js SSR example of how to implement SSR in Next.js:
// pages/index.js
function Home({ data }) {
 return <div>Welcome to {data.name}</div>
}
export async function getServerSideProps() {
 // Fetch data from an API
 const res = await fetch('https://api.example.com/data')
 const data = await res.json()
 // Pass data to the page via props
 return { props: { data } }
}
export default Home

In this example, `getServerSideProps` is called on every request, fetching data from an API and passing it as props to the `Home` component. Next.js automatically handles the server-side rendering process.

Comparison with other frameworks (Gatsby, Razzle)

While Next.js is popular, other frameworks offer different approaches to SSR and static site generation.

Here's a comparison table:

FEATURESNEXT.JSGATSBYRAZZLE
Built-in SSRYesPartialYes
Static GenerationYesYesNo
Learning CurveLowMediumHigh
CustomizationMediumHighHigh
Data FetchingAnyGraphQLAny
RoutingFile-basedPlugin-basedManual
Build PerformanceFastVery FastFast
Community SupportLargeLargeModerate

The right choice of framework depends on your requirements:

  • Next.js provides the best balance of features and ease of use for most SSR React projects.
  • Gatsby would be the best option if you're creating a static site with only occasional dynamic content.
  • For maximum flexibility and control over your SSR setup, Razzle is the best option, optimizing SSR Performance in React.

Caching strategies for SSR

Implementing effective caching can significantly reduce server load and improve response times:

  1. Full page caching: Cache entire rendered pages for a set period.
  2. Component caching: Cache individual components that don't change frequently.

Example of a simple full page caching implementation:

const cache = new Map();
const CACHE_DURATION = 60 * 1000; // 1 minute
app.get('*', (req, res) => {
 const cachedPage = cache.get(req.url);
 if (cachedPage && Date.now() - cachedPage.timestamp < CACHE_DURATION) {
   return res.send(cachedPage.html);
 }
 // Render the page
 const html = ReactDOMServer.renderToString(<App />);
 
 // Cache the rendered page
 cache.set(req.url, { html, timestamp: Date.now() });
 
 res.send(html);
});

Minimizing Time to First Byte (TTFB)

Reducing TTFB is crucial for perceived performance:

  1. Optimize server-side code: Use efficient algorithms and data structures.
  2. Implement database query optimization: Use indexing and query caching.
  3. Use a CDN: Distribute your content geographically closer to users.

Efficient data fetching for SSR

Optimize how and when you fetch data for SSR:

  1. Parallel data fetching: Fetch multiple data sources simultaneously.
  2. Selective hydration: Hydrate critical components first.

Example of parallel data fetching:

async function fetchData() {
 const [userData, postsData] = await Promise.all([
   fetch('/api/user'),
   fetch('/api/posts')
 ]);
 return {
   user: await userData.json(),
   posts: await postsData.json()
 };
}
export async function getServerSideProps() {
 const data = await fetchData();
 return { props: data };
}

Code splitting with SSR

Implement code splitting to reduce the initial JavaScript payload:

  1. Use dynamic imports: Load components only when needed.
  2. Route-based code splitting: Split your app based on routes.

Example using Next.js dynamic imports:

import dynamic from 'next/dynamic';
const DynamicComponent = dynamic(() => import('../components/DynamicComponent'));
function MyPage() {
 return (
   <div>
     <h1>My Page</h1>
     <DynamicComponent />
   </div>
 );
}

By implementing these optimization strategies, you can significantly improve the performance of your SSR React application, providing a better user experience and potentially improving SEO rankings.

With the above optimization techniques implemented in your SSR React application, it will be much faster and improve the user experience; therefore, it can also enhance the SEO rankings.

Winding Up...

Server Side Rendering in React JS is a powerful technique that can significantly enhance the initial load time of your application and more generally the user experience.

While it poses certain implementation challenges, SSR can be a game-changer for projects requiring rapid content delivery or improved search engine visibility.

Implementation of SSR should be made after careful analysis of your project's specific demands, including performance goals, the target audience, and resource constraints.

While React and its ecosystem continue to evolve, up-and-coming SSR techniques and related technologies require up-to-date awareness.

This knowledge shall empower developers to create more robust, efficient, and user-centric web applications that meet modern web users and search algorithms' growing demands in tandem.

Aniket Ashtikar
by Aniket Ashtikar
Technology Architect and Internet Guy

End Slow Growth. Put your Success on Steroids