Understanding SSR and Client Hydration in Next.js
Server‑side rendering (SSR) and client‑side hydration form the foundation of Next.js’s rendering model. SSR delivers HTML already containing user‑specific or dynamic content, while hydration allows React on the client to “take over” this HTML and attach interactivity. Although the high‑level concept seems simple, the actual data‑flow, execution order, streaming pipeline, and hydration triggers are more intricate.
This article explains how SSR works, how data is embedded into server‑rendered HTML, how the client hydrates that HTML, how streaming payloads like self.__next_f are consumed, and how Next.js ensures synchronization—even when scripts load before data arrives.
1. How Server‑Side Rendering Works in Next.js
SSR means the server executes your page code, fetches any required data, and then renders a full HTML document before sending it to the browser.
Step 1: The Server Fetches Data
In the traditional pages‑router, SSR uses getServerSideProps:
export async function getServerSideProps() {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
return { props: { data } };
}In the app‑router, React Server Components perform data fetching during rendering itself.
Step 2: Data Is Serialized and Inserted into the HTML
Next.js embeds server data using script tags so the client can read it during hydration. Historically, this looked like:
<script id="__NEXT_DATA__" type="application/json">
{"props":{"data":{...}}}
</script>In newer versions, an internal streaming buffer—exposed as self.__next_f—transfers RSC payloads.
Step 3: HTML Is Sent to the Browser
The client receives complete HTML containing both:
- The final markup
- Script‑embedded data
Step 4: Hydration Activates the HTML
The client loads your page JS, reads embedded data, and calls hydrateRoot() to attach event listeners and enable reactivity.
2. Data Injection Internals
To understand the SSR pipeline fully, consider a manual example similar to what Next.js performs:
import ReactDOMServer from "react-dom/server";
function App({ message }) {
return <div>{message}</div>;
}
export function renderDocument() {
const props = { message: "Hello from SSR" };
const html = ReactDOMServer.renderToString(<App {...props} />);
const script = `<script>window.__ssr_data = ${JSON.stringify(props)};</script>`;
return `
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
${script}
<script src="/client.js"></script>
</body>
</html>
`;
}The server embeds JSON payloads so the client can reuse them without re‑fetching.
3. Hydration on the Client
Hydration uses hydrateRoot() in modern React:
import { hydrateRoot } from "react-dom/client";
import App from "./App";
const data = window.__ssr_data;
hydrateRoot(document.getElementById("root"), <App {...data} />);Hydration:
- reuses existing DOM from the server,
- attaches listeners,
- activates stateful components,
- verifies structural consistency between server and client.
4. Next.js Streaming and self.__next_f
In the app‑router, Next.js streams React Server Component data through small chunks pushed into an array-like structure:
<script>self.__next_f = self.__next_f || [];</script>
<script>self.__next_f.push([1, "{\"name\":\"Alice\"}"])</script>Each chunk describes UI segments, props, or asynchronous boundaries.
The client receives chunks incrementally, often before hydration is complete.
5. Ensuring Timely Updates to self.__next_f
Observing Changes Using Proxy
A Proxy allows monitoring updates to streamed payloads:
self.__next_f = self.__next_f || [];
self.__next_f = new Proxy(self.__next_f, {
set(target, prop, value) {
const ok = Reflect.set(target, prop, value);
if (prop === "length") {
hydratePayload(target);
}
return ok;
}
});
function hydratePayload(buffer) {
const parsed = JSON.parse(buffer[0][1]);
hydrateRoot(document.getElementById("root"), <App data={parsed} />);
}This mimics how Next.js waits for flight payloads before hydrating components.
6. What Happens if page.js Loads Before Data?
When JavaScript loads faster than the data stream, the client must wait for data before hydrating.
Strategy 1: Initialize Early
<script>self.__next_f = self.__next_f || [];</script>Strategy 2: Inject Data Later
<script>self.__next_f.push([1, "{\"name\":\"The Octocat\"}"]);</script>Strategy 3: Wait for Data Availability
function waitForData() {
return new Promise(resolve => {
if (self.__next_f.length > 0) return resolve();
const interval = setInterval(() => {
if (self.__next_f.length > 0) {
clearInterval(interval);
resolve();
}
}, 10);
});
}
waitForData().then(() => {
const data = JSON.parse(self.__next_f[0][1]);
hydrateRoot(document.getElementById("root"), <App data={data} />);
});This ensures hydration does not run prematurely.
7. Why Correct Ordering Matters
If hydration runs before data is available:
- React cannot render the expected tree.
- The server’s HTML and client’s virtual DOM mismatch.
- Hydration errors appear in the console.
- Full client‑side render may be forced instead.
Thus, Next.js carefully orchestrates script ordering, buffering, and streaming.
Conclusion
SSR and hydration in Next.js combine:
- server‑generated HTML,
- serialized server data,
- streamed RSC payloads (
self.__next_f), - client‑side hydration that reuses the DOM,
- mechanisms that ensure script/data synchronization.
Understanding these mechanics helps diagnose hydration mismatches, build custom SSR frameworks, tune performance, and reason about streaming React Server Components.