Understanding React's Rendering Revolution: hydrateRoot and the New SSR Paradigm
6 May 20255 min read
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:
- The server sends a bare-bones HTML file:
html1 <div id="root"></div>2 <script src="bundle.js"></script>
- The browser downloads the JS bundle, runs React code, and mounts the UI into
#root
. - 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
1 function App() {2 return <h1>Hello, React!</h1>;3 }
SPA Version
Server returns:
1 <div id="app-root"></div>2 <script src="main.js"></script>
Client loads JS:
1 import { createRoot } from 'react-dom/client';2 import App from './App';34 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):
1 import { renderToString } from 'react-dom/server';2 import App from './App';34 const htmlMarkup = renderToString(<App />);
Server returns:
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:
1 import { hydrateRoot } from 'react-dom/client';2 import App from './App';34 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.
1 import { hydrateRoot } from 'react-dom/client';2 import { Suspense } from 'react';34 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.
1 import { renderToPipeableStream } from 'react-dom/server';23 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
1 function Info() {2 return <p>{window.innerWidth}</p>; // ⛔ SSR: `window` is undefined3 }
✅ Fix:
1 function Info() {2 const [width, setWidth] = useState(0);3 useEffect(() => {4 setWidth(window.innerWidth);5 }, []);6 return <p>{width}</p>;7 }
❌ Random Values
1 function Randomizer() {2 return <p>{Math.random()}</p>; // ⚠️ Server/client mismatch3 }
✅ Fix:
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
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:
1 // pages/index.tsx2 export async function getServerSideProps() {3 const user = await fetchUserData();4 return { props: { user } };5 }67 function Profile({ user }) {8 return <div>{user.name}</div>;9 }
Real-World Example: Next.js
Next.js wraps SSR/SSG for you.
SSG:
1 // pages/index.tsx2 export async function getStaticProps() {3 return { props: { greeting: 'Hello from static site!' } };4 }56 export default function Home({ greeting }) {7 return <h1>{greeting}</h1>;8 }
SSR:
1 // pages/index.tsx2 export async function getServerSideProps() {3 return { props: { timestamp: new Date().toISOString() } };4 }56 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:
1 const express = require('express');2 const { renderToString } = require('react-dom/server');3 const App = require('./App').default;45 const app = express();6 app.use(express.static('.'));78 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 });1920 app.listen(3000);
Client:
1 // client.js2 import { hydrateRoot } from 'react-dom/client';3 import App from './App';45 hydrateRoot(document.getElementById('app-root'), <App />);
App:
1 // App.js2 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.