Building a Basic React SSR Framework from Scratch
Grace Collins
Solutions Engineer · Leapcell

Introduction to Server-Side Rendering with React
In the dynamic landscape of web development, user experience and search engine optimization (SEO) are paramount. Modern single-page applications (SPAs) built with libraries like React offer rich interactive experiences but often face challenges in initial loading performance and search engine indexing. This is where Server-Side Rendering (SSR) comes into play. SSR allows us to render React components into static HTML on the server, sending fully sculpted pages to the client. This approach significantly improves the perceived loading time, as users see content immediately, and provides a crawlable HTML structure that search engines prefer. This article will guide you through the process of building a simple, yet functional, React SSR framework from the ground up, demystifying the underlying mechanisms and empowering you to leverage its benefits.
Understanding the Core Concepts of SSR
Before diving into the implementation, let's establish a clear understanding of the key concepts involved in React SSR.
React Components
At the heart of any React application are components. These are reusable, self-contained pieces of UI written using JSX. For SSR, these same components will be rendered on the server.
ReactDOMServer
This is a crucial package provided by React that allows you to render React components to static HTML strings. Specifically, renderToString
and renderToStaticMarkup
are the functions we'll rely on. renderToString
is generally preferred as it includes data-reactid attributes, allowing React to "hydrate" the component on the client-side, making it interactive without re-rendering the entire DOM.
Client-Side Hydration
After the server sends the HTML, the client-side React code "attaches" to this pre-rendered HTML. This process, known as hydration, transforms the static HTML into a fully interactive React application, preserving the server-rendered DOM structure and avoiding a flicker or re-render.
Express.js
A minimalist web framework for Node.js, Express.js will serve as our server, handling HTTP requests, rendering our React application, and sending the resulting HTML back to the client.
Babel
Since we'll be writing our React components using JSX and potentially modern JavaScript features, Babel will be essential for transpiling our code into a format that Node.js and client-side browsers can understand.
Building a Minimalistic React SSR Framework
Let's begin constructing our SSR framework step-by-step.
Project Setup
First, create a new project directory and initialize it:
mkdir react-ssr-framework cd react-ssr-framework npm init -y
Next, install the necessary dependencies:
npm install react react-dom express @babel/core @babel/preset-env @babel/preset-react babel-loader webpack webpack-node-externals npm install --save-dev nodemon
We'll need two separate Babel configurations: one for the server (which uses Node.js features and doesn't need to worry about browser compatibility as much) and one for the client. For simplicity, we'll use a single .babelrc
for both in this example, ensuring browser compatibility for client-side code.
Create a .babelrc
file:
{ "presets": ["@babel/preset-env", "@babel/preset-react"] }
Our First React Component
Let's create a simple App.js
component that will be rendered on both the server and the client.
// src/components/App.js import React from 'react'; const App = ({ message }) => { return ( <div> <h1>Hello from React SSR!</h1> <p>{message}</p> <button onClick={() => alert('This is an interactive button!')}> Click Me </button> </div> ); }; export default App;
Server-Side Rendering Logic
Now, let's set up our server using Express.js to render our App
component.
// src/server/index.js import express from 'express'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; import App from '../components/App'; const app = express(); app.get('/', (req, res) => { const initialProps = { message: 'This content was rendered on the server!' }; const appString = ReactDOMServer.renderToString(<App {...initialProps} />); res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>React SSR App</title> </head> <body> <div id="root">${appString}</div> <script> window.__INITIAL_PROPS__ = ${JSON.stringify(initialProps)}; </script> <script src="/client_bundle.js"></script> </body> </html> `); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); });
Notice a few key details:
- We're using
ReactDOMServer.renderToString
to get the HTML string. - We're embedding the
appString
directly into the HTML response. window.__INITIAL_PROPS__
is a global variable holding the same props used for server rendering. This is crucial for client-side hydration.script src="/client_bundle.js"
indicates that we'll serve a client-side JavaScript bundle.
Client-Side Hydration
The client-side bundle will be responsible for rehydrating our application.
// src/client/index.js import React from 'react'; import ReactDOM from 'react-dom'; import App from '../components/App'; // Retrieve the initial props sent from the server const initialProps = window.__INITIAL_PROPS__; ReactDOM.hydrate(<App {...initialProps} />, document.getElementById('root'));
Here, ReactDOM.hydrate
is used instead of ReactDOM.render
. hydrate
tells React to attempt to attach to the existing HTML markup instead of re-rendering everything.
Bundling with Webpack
We need to bundle our client-side JavaScript to be served to the browser. We also need to compile our server-side code, as Node.js doesn't natively understand JSX or ES Modules for import
/export
without pre-processing.
Create a webpack.config.js
file:
// webpack.config.js const path = require('path'); const nodeExternals = require('webpack-node-externals'); const clientConfig = { mode: 'development', entry: './src/client/index.js', output: { path: path.resolve(__dirname, 'public'), filename: 'client_bundle.js', }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', }, }, ], }, }; const serverConfig = { mode: 'development', target: 'node', // Crucial for server bundles externals: [nodeExternals()], // Prevents bundling node_modules dependencies entry: './src/server/index.js', output: { path: path.resolve(__dirname, 'build'), filename: 'server_bundle.js', }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', }, }, ], }, }; module.exports = [clientConfig, serverConfig];
We have two configurations: clientConfig
bundles our client-side code into public/client_bundle.js
, and serverConfig
bundles our server-side code into build/server_bundle.js
. The target: 'node'
and externals: [nodeExternals()]
in serverConfig
are important for a Node.js environment.
Serving Static Assets
Our server needs to serve the client-side bundle. Add a line to src/server/index.js
before the app.get
route:
// src/server/index.js (add this line) app.use(express.static('public')); // Serve static files from the 'public' directory
npm Scripts
Add scripts to your package.json
to build and run the application:
// package.json { "name": "react-ssr-framework", // ... other fields "scripts": { "build:client": "webpack --config webpack.config.js --env.target=client", "build:server": "webpack --config webpack.config.js --env.target=server", "build": "webpack --config webpack.config.js", "start": "node ./build/server_bundle.js", "dev": "npm run build && nodemon --watch build --exec \"npm start\"" }, // ... other fields }
Now, run npm run build
to create the initial bundles, then npm run dev
to start the server with auto-restarts on server-bundle changes.
Testing the SSR
Navigate to http://localhost:3000
in your browser. You should see "Hello from React SSR!" and "This content was rendered on the server!". Critically, if you view the page source (not inspect element), you'll find the React HTML directly embedded in the source, confirming server-side rendering. The button should also be interactive, demonstrating client-side hydration.
Application Scenarios for SSR
This basic framework, while simple, demonstrates the core principles applicable in various scenarios:
- Improved SEO: Search engine crawlers can easily parse the pre-rendered HTML, leading to better indexing and ranking.
- Faster Initial Load: Users see meaningful content much quicker, enhancing perceived performance and reducing bounce rates.
- Better Accessibility: The initial HTML is available immediately, which can be beneficial for users with slower internet connections or older devices.
- Open Graph Tags and Social Sharing: Specific metadata for social media sharing (e.g., og
, og) can be dynamically rendered on the server based on the specific page content.
Conclusion
We have successfully built a rudimentary React SSR framework, illustrating the vital interplay between React components, ReactDOMServer for server rendering, Express.js for serving, and client-side hydration. This setup transforms a static initial view into an interactive application, offering a foundational understanding of how to achieve improved performance and SEO for React applications. By rendering HTML on the server and then hydrating it on the client, we deliver a superior user experience and appease search engine algorithms, making SSR a cornerstone for modern, high-performance web development.