
Headless CMS scales and improves WPWhiteBoard’s content distribution, flexibility, and personalization
Aniket Ashtikar
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.
This exhaustive tutorial will cover everything regarding server side rendering in React JS, from the conceptualization to the advanced implementation techniques and optimization methods.
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:
This approach combines the best of both worlds: fast initial page loads and fully interactive applications.
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:
Understanding these fundamental concepts of SSR in React JS sets the stage for diving deeper into its benefits, challenges, and implementation strategies.
1. Required dependencies
To get started with SSR in React, you'll need to install the following dependencies:
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.
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.
We need two webpack configurations: one for the client and one for the server.
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()]
};
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')
);
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')
);
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.
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.
// 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.
While Next.js is popular, other frameworks offer different approaches to SSR and static site generation.
Here's a comparison table:
FEATURES | NEXT.JS | GATSBY | RAZZLE |
Built-in SSR | Yes | Partial | Yes |
Static Generation | Yes | Yes | No |
Learning Curve | Low | Medium | High |
Customization | Medium | High | High |
Data Fetching | Any | GraphQL | Any |
Routing | File-based | Plugin-based | Manual |
Build Performance | Fast | Very Fast | Fast |
Community Support | Large | Large | Moderate |
The right choice of framework depends on your requirements:
Implementing effective caching can significantly reduce server load and improve response times:
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);
});
Optimize how and when you fetch data for SSR:
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 };
}
Implement code splitting to reduce the initial JavaScript payload:
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.
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.