JavaScript Development Space

Understanding React's Rendering Revolution: hydrateRoot and the New SSR Paradigm

6 May 20255 min read
From SPA to SSR and SSG in React 18: The Shift Behind hydrateRoot

When React (alongside Vue and Angular) first surged in popularity, the front-end world embraced Single Page Applications (SPA) as the go-to architecture. But as demands for better SEO and faster loading times grew, Server-Side Rendering (SSR) and Static Site Generation (SSG) rose back into relevance — although, truthfully, these aren’t new ideas. They’ve existed in the web’s earlier PHP-powered era, just under different names.

This article explains what changed, especially through the lens of React 18’s hydrateRoot API. We’ll compare SPA vs SSR vs SSG, walk through real code, and dive into how React brings static HTML back to life with interactivity.

Why SSR and SSG Emerged from SPA

The Traditional SPA Flow

In a React SPA, here’s what typically happens:

  1. The server sends a bare-bones HTML file:
    html
    1 <div id="root"></div>
    2 <script src="bundle.js"></script>
  2. The browser downloads the JS bundle, runs React code, and mounts the UI into #root.
  3. The user sees a fully rendered page only after JavaScript executes.

Problems with This Flow:

  • Slow First Paint: Until JS loads and runs, the screen stays blank.
  • Poor SEO: Search engines can’t crawl content that’s dynamically rendered after page load.

Enter SSR and SSG

To solve these problems:

SSR (Server-Side Rendering):

  • The server runs React code (via renderToString) and sends fully rendered HTML.
  • The user sees content immediately, before React takes over to make the page interactive.

SSG (Static Site Generation):

  • HTML is pre-rendered at build time.
  • The server returns static files (like index.html).
  • Ideal for blogs, docs, and other content that doesn’t change often.

Both approaches improve perceived performance and boost SEO.

The Role of hydrateRoot

In SSR/SSG, the HTML already exists when the page loads. React’s job is not to re-render, but to attach interactivity to the existing DOM — that’s where hydrateRoot() comes in.

Analogy:

Think of the HTML as a “cold dish” served by the server. hydrateRoot() is like a microwave — it “reheats” the markup and makes it interactive.

Code Walkthrough: SPA vs SSR

Basic Component

tsx
1 function App() {
2 return <h1>Hello, React!</h1>;
3 }

SPA Version

Server returns:

html
1 <div id="app-root"></div>
2 <script src="main.js"></script>

Client loads JS:

tsx
1 import { createRoot } from 'react-dom/client';
2 import App from './App';
3
4 const domRoot = document.getElementById('app-root');
5 createRoot(domRoot).render(<App />);

✅ Works, but no content until JS executes.

SSR Version

Server-side code (Node.js):

tsx
1 import { renderToString } from 'react-dom/server';
2 import App from './App';
3
4 const htmlMarkup = renderToString(<App />);

Server returns:

html
1 <html>
2 <body>
3 <div id="app-root">
4 <h1>Hello, React!</h1>
5 </div>
6 <script src="/client.js"></script>
7 </body>
8 </html>

Client hydration:

tsx
1 import { hydrateRoot } from 'react-dom/client';
2 import App from './App';
3
4 const domRoot = document.getElementById('app-root');
5 hydrateRoot(domRoot, <App />);

✅ Content is visible instantly, then React hydrates it for interactivity.

Why Not createRoot?

If you used createRoot() in an SSR/SSG context, React would wipe the HTML and start from scratch — causing:

  • Page flicker
  • Lost performance gains

hydrateRoot() instead reuses the existing DOM, only binding event listeners and internal state.

React 18's Enhancements to SSR and SSG

1. Concurrent Rendering

  • React can pause/resume hydration.
  • UI remains responsive even while hydration is running.

2. Selective Hydration

  • Hydrate only critical components first — delay the rest.
tsx
1 import { hydrateRoot } from 'react-dom/client';
2 import { Suspense } from 'react';
3
4 hydrateRoot(
5 document.getElementById('app-root'),
6 <Suspense fallback={<p>Loading content...</p>}>
7 <App />
8 </Suspense>
9 );

✅ Useful when parts of the UI don’t need to be interactive right away.

3. Streaming HTML with renderToPipeableStream

Server can stream HTML chunks to the client before everything finishes rendering.

tsx
1 import { renderToPipeableStream } from 'react-dom/server';
2
3 const { pipe } = renderToPipeableStream(<App />, {
4 onShellReady() {
5 pipe(res); // Start streaming!
6 }
7 });

✅ Users see content sooner.

4. Error Tolerance

Hydration mismatch no longer crashes the app immediately — React 18 attempts recovery where possible.

Common Hydration Pitfalls (And Fixes)

❌ Client-Only APIs

tsx
1 function Info() {
2 return <p>{window.innerWidth}</p>; // ⛔ SSR: `window` is undefined
3 }

✅ Fix:

tsx
1 function Info() {
2 const [width, setWidth] = useState(0);
3 useEffect(() => {
4 setWidth(window.innerWidth);
5 }, []);
6 return <p>{width}</p>;
7 }

❌ Random Values

tsx
1 function Randomizer() {
2 return <p>{Math.random()}</p>; // ⚠️ Server/client mismatch
3 }

✅ Fix:

tsx
1 function Randomizer() {
2 const [value, setValue] = useState(0);
3 useEffect(() => {
4 setValue(Math.random());
5 }, []);
6 return <p>{value}</p>;
7 }

❌ Async Data Without Sync on Server

tsx
1 function Profile() {
2 const [user, setUser] = useState(null);
3 useEffect(() => {
4 fetch('/api/user').then(res => res.json()).then(setUser);
5 }, []);
6 return <div>{user?.name}</div>;
7 }

✅ In SSR, fetch the data before rendering:

tsx
1 // pages/index.tsx
2 export async function getServerSideProps() {
3 const user = await fetchUserData();
4 return { props: { user } };
5 }
6
7 function Profile({ user }) {
8 return <div>{user.name}</div>;
9 }

Real-World Example: Next.js

Next.js wraps SSR/SSG for you.

SSG:

tsx
1 // pages/index.tsx
2 export async function getStaticProps() {
3 return { props: { greeting: 'Hello from static site!' } };
4 }
5
6 export default function Home({ greeting }) {
7 return <h1>{greeting}</h1>;
8 }

SSR:

tsx
1 // pages/index.tsx
2 export async function getServerSideProps() {
3 return { props: { timestamp: new Date().toISOString() } };
4 }
5
6 export default function Home({ timestamp }) {
7 return <h1>Server Time: {timestamp}</h1>;
8 }

hydrateRoot happens automatically under the hood.

Try It Yourself: Manual SSR with Express

Server:

js
1 const express = require('express');
2 const { renderToString } = require('react-dom/server');
3 const App = require('./App').default;
4
5 const app = express();
6 app.use(express.static('.'));
7
8 app.get('/', (req, res) => {
9 const html = renderToString(<App />);
10 res.send(`
11 <html>
12 <body>
13 <div id="app-root">${html}</div>
14 <script src="/client.js"></script>
15 </body>
16 </html>
17 `);
18 });
19
20 app.listen(3000);

Client:

tsx
1 // client.js
2 import { hydrateRoot } from 'react-dom/client';
3 import App from './App';
4
5 hydrateRoot(document.getElementById('app-root'), <App />);

App:

tsx
1 // App.js
2 export default function App() {
3 return <h1>Hello, SSR!</h1>;
4 }

Conclusion: The “Magic” Behind hydrateRoot

React 18’s hydrateRoot brings server-rendered HTML to life:

  • Keeps the DOM intact (no flicker)
  • Binds React state and events
  • Supports advanced patterns like streaming and partial hydration

Whether you’re using a full framework like Next.js or a custom Express server, understanding this core hydration step will help you debug issues and build faster, more SEO-friendly apps.

JavaScript Development Space

JSDev Space – Your go-to hub for JavaScript development. Explore expert guides, best practices, and the latest trends in web development, React, Node.js, and more. Stay ahead with cutting-edge tutorials, tools, and insights for modern JS developers. 🚀

Join our growing community of developers! Follow us on social media for updates, coding tips, and exclusive content. Stay connected and level up your JavaScript skills with us! 🔥

© 2025 JavaScript Development Space - Master JS and NodeJS. All rights reserved.