Headless CMS scales and improves WPWhiteBoard’s content distribution, flexibility, and personalization
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.
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:
This approach combines the best of both worlds: fast initial page loads and fully interactive applications.
Key differences between SSR and CSR
- 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.
- 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.
- 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.
- Complexity: SSR usually involves more complex setup and maintenance. Developers must take into account both server and client environments while writing code.
- 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:
- JavaScript Everywhere: Node.js enables both client and server to run JavaScript so that developers are not context switching.
- Efficient I/O: Node.js's event-driven, non-blocking I/O model makes it well-suited for handling SSR requests efficiently.
- npm Ecosystem: The vast npm ecosystem provides numerous tools and libraries to facilitate SSR implementation.
- 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
Challenges
- 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.
- Higher Load on Server:
Server-side rendering can increase the usage of CPU and memory, making it potentially costly to host and scale.
- 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.
- 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.
- 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:
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.
- 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.
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.
- Overview and benefits
- Automatic code splitting: Your code will be automatically split into smaller chunks to reduce load times.
- File-based routing: Create routes with minimal hassle by simply adding files into the `pages` directory.
- API routes: Easily create API endpoints as part of your Next.js app.
- Static site generation (SSG): Static pages are generated at build time to achieve the maximum speed
- Image optimization: Automatic image compression and resizing
- Zero config: Out-of-the box, with sane defaults.
- 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:
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:
Caching strategies for SSR
Implementing effective caching can significantly reduce server load and improve response times:
- Full page caching: Cache entire rendered pages for a set period.
- 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)
- Optimize server-side code: Use efficient algorithms and data structures.
- Implement database query optimization: Use indexing and query caching.
- Use a CDN: Distribute your content geographically closer to users.
Efficient data fetching for SSR
Optimize how and when you fetch data for SSR:
- Parallel data fetching: Fetch multiple data sources simultaneously.
- 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:
- Use dynamic imports: Load components only when needed.
- 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.
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.