Understanding SSR and Client Hydration in Next.js

January, 13th 2025 4 min read

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:

jsx
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:

html
<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:

jsx
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:

jsx
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:

html
<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:

js
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

html
<script>self.__next_f = self.__next_f || [];</script>

Strategy 2: Inject Data Later

html
<script>self.__next_f.push([1, "{\"name\":\"The Octocat\"}"]);</script>

Strategy 3: Wait for Data Availability

js
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.