# Server-Side Rendering

Course: [React](https://www.devtutorials.ai/learn/react) · Section: Render Modes
Source: https://www.devtutorials.ai/learn/react/rendering/server-side-rendering

> How SSR works: the server rendering process, request-response lifecycle, HTML generation, hydration, server-side data fetching, SSR pros and cons, and a Blog Portal capstone to download.

## Introduction to Server-Side Rendering

**Server-side rendering (SSR)** means React runs on the **server** first. The server turns your components into finished HTML and sends it to the browser. The user sees the page at once, before any JavaScript has run.

Then the JavaScript loads, and React makes the page interactive. This step is called **hydration**.

### Real-World Example

A bookshop's book page must appear in Google results and show quickly on slow phones. For each request to `/books/42`, the server loads the book, runs `renderToString(<BookPage />)`, and sends finished HTML.

The user sees the title and price at once. Then the JavaScript loads, and `hydrateRoot` makes the **Add to cart** button work.

<figure className="react-fig">
<svg viewBox="0 0 520 120" role="img" aria-labelledby="fig-ssr-flow" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-flow">Server-side rendering: the server runs React and sends finished HTML, then the browser adds interactivity</title>
  <rect x="5" y="20" width="95" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="52.5" y="42.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Browser</text>
  <text x="52.5" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>asks for /books</text>
  <path d="M100 45 L130 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M130 45 L121.7 48.5 L121.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="135" y="20" width="130" height="50" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="200.0" y="42.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Server</text>
  <text x="200.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>runs React</text>
  <path d="M265 45 L295 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M295 45 L286.7 48.5 L286.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="300" y="20" width="110" height="50" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="355.0" y="42.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Full HTML</text>
  <text x="355.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>&lt;h1&gt;Books...</text>
  <path d="M410 45 L430 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M430 45 L421.7 48.5 L421.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="435" y="20" width="80" height="50" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="475.0" y="42.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Page</text>
  <text x="475.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>visible now</text>
  <text x="260" y="105" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 12, fontWeight: 700, fontFamily: "var(--sans)" }}>Then the JavaScript loads and hydrates the page</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="3s" repeatCount="indefinite" path="M100 45 L130 45 M265 45 L295 45 M410 45 L430 45" />
  </circle>
</svg>
<figcaption>The user sees content before any JavaScript has run.</figcaption>
</figure>

You rarely build SSR by hand. Frameworks like **Next.js**, **React Router (Remix)** and **Astro** do it for you. This lesson shows what happens inside, using React's real functions `renderToString` and `hydrateRoot`. In the labs, one file plays the part of the server.

## Benefits of SSR

- **Fast first view.** Content shows as soon as the HTML arrives, even on slow phones.
- **Better SEO.** Search engines read full HTML, not an empty `root` div.
- **Good link previews.** Social apps show the real title and text when someone shares a link.
- **Works before JavaScript.** Users can read the page while the JavaScript is still loading.

<figure className="react-fig">
<svg viewBox="0 0 520 150" role="img" aria-labelledby="fig-ssr-time" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-time">Timeline: with CSR the screen is blank until JavaScript runs. With SSR content shows first and becomes interactive later</title>
  <text x="10" y="30" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>CSR</text>
  <rect x="60" y="15" width="200" height="26" rx="8" style={{ fill: "var(--bad-soft)", stroke: "var(--bad)", strokeWidth: 1.5 }} />
  <text x="160.0" y="33.0" textAnchor="middle" style={{ fill: "var(--bad)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>blank page</text>
  <rect x="262" y="15" width="80" height="26" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="302.0" y="33.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>load JS</text>
  <rect x="344" y="15" width="170" height="26" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="429.0" y="33.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>content + interactive</text>
  <text x="10" y="80" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>SSR</text>
  <rect x="60" y="65" width="60" height="26" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="90.0" y="83.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>server</text>
  <rect x="122" y="65" width="220" height="26" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="232.0" y="83.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>content visible (not interactive)</text>
  <rect x="344" y="65" width="170" height="26" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="429.0" y="83.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>hydrated: interactive</text>
  <line x1="60" y1="110" x2="60" y2="110" style={{ stroke: "var(--accent)", strokeWidth: 3 }}>
    <animate attributeName="x2" values="60;514" dur="5s" repeatCount="indefinite" />
  </line>
  <text x="287" y="135" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>time →</text>
</svg>
<figcaption>SSR shows content sooner. Both become interactive when the JavaScript is ready.</figcaption>
</figure>

## SSR Architecture

An SSR app has two parts that use the **same components**:

- **Server** (Node.js): gets a request, renders the components to HTML, sends it.
- **Browser**: loads the same components as JavaScript, and **hydrates** the HTML.

Because the code runs in two places, it must work in both. For example, `window` and `document` exist only in the browser, so do not use them while rendering.

<figure className="react-fig">
<svg viewBox="0 0 520 170" role="img" aria-labelledby="fig-ssr-arch" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-arch">SSR architecture: the same React components run on the server to make HTML and in the browser to hydrate it</title>
  <rect x="185" y="10" width="150" height="44" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="260.0" y="29.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Your components</text>
  <text x="260.0" y="46.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>App.tsx</text>
  <path d="M220 54 L120 96" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M120 96 L126.3 89.6 L129.0 96.0 Z" style={{ fill: "var(--faint)" }} />
  <path d="M300 54 L400 96" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M400 96 L391.0 96.0 L393.7 89.6 Z" style={{ fill: "var(--faint)" }} />
  <rect x="20" y="100" width="200" height="56" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="120.0" y="125.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Server (Node.js)</text>
  <text x="120.0" y="142.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>renderToString → HTML</text>
  <rect x="300" y="100" width="200" height="56" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="400.0" y="125.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Browser</text>
  <text x="400.0" y="142.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>hydrateRoot → events</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="2.2s" repeatCount="indefinite" path="M220 54 L120 96" />
  </circle>
  <circle r="6" style={{ fill: "var(--ok)" }}>
    <animateMotion dur="2.2s" repeatCount="indefinite" path="M300 54 L400 96" />
  </circle>
</svg>
<figcaption>One set of components, two places they run.</figcaption>
</figure>

### Server Rendering Process

On the server, React uses `renderToString` from `react-dom/server`:

```tsx
import { renderToString } from "react-dom/server";

const html = renderToString(<App />);
// "<div><h2>Book Corner</h2>...</div>"
```

It calls your components **once** and returns plain HTML text. It does not run `useEffect` and does not attach click handlers. The console in the lab shows the exact string.

      <h2>{"Book Corner"}</h2>
      <ul>
        {books.map((b) => (
          <li key={b.id}>{b.title + " - ₹" + b.price}</li>
        ))}
      </ul>
    </div>
  );
}
`,
    "main.tsx": `import { renderToString } from "react-dom/server";

// SERVER: render the app to an HTML string
const html = renderToString(<App />);
console.log("HTML from the server: " + html);

// Show the string as text, so you can read it
const pre = document.createElement("pre");
pre.style.whiteSpace = "pre-wrap";
pre.textContent = html;
document.getElementById("root")!.appendChild(pre);
`,
  }}
/>

### Request-Response Lifecycle

When a user opens `https://bookcorner.dev/posts/2`:

1. The browser sends a **request** for `/posts/2`.
2. The server **matches the route**: `/posts/:id` with `id = 2`.
3. The server **fetches the data** for post 2.
4. The server **renders** the page to HTML with that data.
5. The server sends the **response**: a status code (200, or 404) and a full HTML page.

<figure className="react-fig">
<svg viewBox="0 0 520 130" role="img" aria-labelledby="fig-ssr-req" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-req">Request-response lifecycle: request, match route, fetch data, render HTML, send response</title>
  <rect x="5" y="30" width="96" height="52" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="53.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Request</text>
  <text x="53.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>GET /posts/2</text>
  <rect x="109" y="30" width="96" height="52" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="157.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Match route</text>
  <text x="157.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>/posts/:id</text>
  <rect x="213" y="30" width="96" height="52" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="261.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Fetch data</text>
  <text x="261.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>post 2</text>
  <rect x="317" y="30" width="96" height="52" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="365.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Render</text>
  <text x="365.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>to HTML</text>
  <rect x="421" y="30" width="96" height="52" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="469.0" y="53.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Response</text>
  <text x="469.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>200 + HTML</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="4s" repeatCount="indefinite" path="M53 56 L469 56" />
  </circle>
  <text x="260" y="108" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>all of this happens before the browser shows anything</text>
</svg>
<figcaption>The server does the work, then sends one finished page.</figcaption>
</figure>

### HTML Generation on the Server

The server does not send only the app's HTML. It puts it into a full page **template**, at a placeholder inside the `root` div. It also adds the page data and the `<script>` tag for the JavaScript.

Click the buttons in the lab to send "requests". The pretend server picks the page, sets the status, and returns a complete HTML document.

<figure className="react-fig">
<svg viewBox="0 0 520 160" role="img" aria-labelledby="fig-ssr-tpl" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-tpl">HTML generation: the server puts the app's HTML and data into a page template</title>
  <rect x="10" y="10" width="240" height="140" rx="10" style={{ fill: "var(--panel2)", stroke: "var(--faint)" }} />
  <text x="25" y="34" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 400, fontFamily: "var(--mono)" }}>&lt;html&gt;&lt;body&gt;</text>
  <text x="35" y="58" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 400, fontFamily: "var(--mono)" }}>&lt;div id="root"&gt;</text>
  <rect x="45" y="66" width="190" height="26" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="140.0" y="84.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>&lt;!--app-html--&gt;</text>
  <text x="35" y="112" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 12, fontWeight: 400, fontFamily: "var(--mono)" }}>&lt;/div&gt;</text>
  <rect x="35" y="118" width="200" height="24" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="135.0" y="135.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>&lt;!--app-data--&gt;</text>
  <rect x="300" y="30" width="210" height="44" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="405.0" y="57.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>renderToString(&lt;App /&gt;)</text>
  <rect x="300" y="100" width="210" height="44" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="405.0" y="127.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>JSON of the page data</text>
  <path d="M300 52 L240 78" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M240 78 L246.2 71.5 L249.0 77.9 Z" style={{ fill: "var(--faint)" }} />
  <path d="M300 122 L240 130" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M240 130 L247.8 125.4 L248.7 132.4 Z" style={{ fill: "var(--faint)" }} />
  <circle r="6" style={{ fill: "var(--accent)" }}>
    <animateMotion dur="2s" repeatCount="indefinite" path="M300 52 L240 78" />
  </circle>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="2s" repeatCount="indefinite" path="M300 122 L240 130" />
  </circle>
</svg>
<figcaption>Two placeholders are filled, and the page is ready to send.</figcaption>
</figure>

      <p>
        Request:{" "}
        {["/", "/books/2", "/about", "/nope"].map((u) => (
          <button key={u} onClick={() => setUrl(u)}>{u}</button>
        ))}
      </p>
      <p>Status: <b>{response.status}</b></p>
      <pre style={{ whiteSpace: "pre-wrap", background: "#f4f4f4", padding: 8 }}>{response.body}</pre>
    </div>
  );
}
`,
    "server.tsx": `import { renderToString } from "react-dom/server";

const books = [
  { id: 1, title: "The Alchemist" },
  { id: 2, title: "Wings of Fire" },
];

const template =
  "<!DOCTYPE html>\\n<html>\\n<body>\\n" +
  '  <div id="root"><!--app-html--></div>\\n' +
  '  <script src="/app.js"></script>\\n' +
  "</body>\\n</html>";

// The server: URL in, status + HTML document out
  let page;
  let status = 200;
  const match = /^\\/books\\/(\\d+)$/.exec(url);

  if (url === "/") page = <h1>Welcome to Book Corner</h1>;
  else if (url === "/about") page = <h1>About us</h1>;
  else if (match) {
    const book = books.find((b) => b.id === Number(match[1]));
    page = <h1>{"Book: " + (book ? book.title : "not found")}</h1>;
  } else {
    status = 404;
    page = <h1>Page not found</h1>;
  }

  const html = renderToString(page);
  return { status, body: template.replace("<!--app-html-->", html) };
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

## SSR Rendering Fundamentals

### Initial Page Render

With SSR, the **first** page the user opens is rendered on the server. The browser receives full HTML and shows it at once. There is no blank screen.

### Server-Generated Content

Compare what the browser receives:

```html
<!-- CSR: empty until JavaScript runs -->
<div id="root"></div>

<!-- SSR: the content is already there -->
<div id="root"><h2>Book Corner</h2><ul><li>The Alchemist - ₹299</li>...</ul></div>
```

This server-generated content is plain HTML. Search engines and slow phones can read it with no JavaScript at all.

### Client Interactivity

Server HTML **looks** ready, but it is not interactive yet. Buttons and links handled by React do nothing until the JavaScript loads and hydrates the page. After that, the app works like any CSR app.

Press **Load page** in the simulator below. Watch the stages, and try the mock page's button at each stage.

      <button onClick={() => { setStage(0); setLikes(0); setMissed(0); }}>Load page</button>
      <svg viewBox="0 0 480 50" style={{ width: "100%", marginTop: 8 }}>
        {stages.map((s, i) => (
          <g key={s.name}>
            <rect x={i * 96 + 2} y={6} width={90} height={36} rx={8}
              fill={i <= stage ? s.color : "#f1f3f7"} stroke={s.color} />
            <text x={i * 96 + 47} y={29} textAnchor="middle" fontSize={11} fontWeight="bold"
              fill={i <= stage ? "white" : s.color}>{s.name}</text>
          </g>
        ))}
      </svg>
      <p>{stage >= 0 ? stages[stage].note : "Press Load page to start."}</p>
      <div style={{ border: "2px dashed #bbb", borderRadius: 8, padding: 10, minHeight: 90, background: visible ? "white" : "#eee" }}>
        {visible ? (
          <>
            <b>Atomic Habits</b> · ₹499
            <br />
            <button
              style={{ marginTop: 6, opacity: hydrated ? 1 : 0.6 }}
              onClick={() => (hydrated ? setLikes(likes + 1) : setMissed(missed + 1))}
            >
              ♥ Like ({likes})
            </button>
            {missed > 0 && <p style={{ color: "#b42318" }}>{missed} click(s) did nothing: not hydrated yet.</p>}
          </>
        ) : (
          <span style={{ color: "#999" }}>{stage < 0 ? "" : "(nothing to show yet)"}</span>
        )}
      </div>
    </div>
  );
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

Easy Way to Remember: the server makes it visible, hydration makes it clickable.

## Hydration Process

### What is Hydration?

**Hydration** is how React makes server HTML interactive. React walks through the HTML already on the page, matches it with your components, and connects them. It does **not** throw the HTML away and draw it again.

In the browser, use `hydrateRoot` instead of `createRoot`:

```tsx
import { hydrateRoot } from "react-dom/client";

hydrateRoot(document.getElementById("root")!, <App />);
```

<figure className="react-fig">
<svg viewBox="0 0 520 140" role="img" aria-labelledby="fig-ssr-hydrate" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-hydrate">Hydration: React attaches event handlers to HTML that is already on the page, so it becomes interactive</title>
  <rect x="10" y="20" width="220" height="100" rx="10" style={{ fill: "var(--panel2)", stroke: "var(--faint)" }} />
  <text x="120" y="48" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Atomic Habits ₹499</text>
  <rect x="60" y="62" width="120" height="32" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="120.0" y="83.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Add to cart</text>
  <text x="120" y="112" textAnchor="middle" style={{ fill: "var(--bad)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>looks ready, but clicks do nothing</text>
  <path d="M235 70 L280 70" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M280 70 L271.7 73.5 L271.7 66.5 Z" style={{ fill: "var(--faint)" }} />
  <text x="258" y="60" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 11, fontWeight: 700, fontFamily: "var(--mono)" }}>hydrate</text>
  <rect x="290" y="20" width="220" height="100" rx="10" style={{ fill: "var(--panel2)", stroke: "var(--faint)" }} />
  <text x="400" y="48" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Atomic Habits ₹499</text>
  <rect x="340" y="62" width="120" height="32" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--ok)", strokeWidth: 1.5 }}>
    <animate attributeName="fill" values="var(--panel2);var(--ok-soft);var(--ok-soft);var(--panel2)" keyTimes="0;0.2;0.85;1" dur="4s" repeatCount="indefinite" />
  </rect>
  <text x="400" y="83" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Add to cart</text>
  <text x="400" y="112" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>same HTML, now clicks work</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="4s" repeatCount="indefinite" path="M235 70 L280 70" />
  </circle>
</svg>
<figcaption>Hydration does not redraw the page. It wakes up the HTML that is already there.</figcaption>
</figure>

### Attaching Event Handlers

Server HTML has no `onClick` code in it: HTML text cannot hold JavaScript functions. During hydration, React finds each element and **attaches** the handlers from your components. It also starts state and runs `useEffect`.

In the lab, the server HTML is shown at once, and the "JavaScript" arrives 3 seconds later. Click the button before and after.

      <h2>Atomic Habits</h2>
      <p>{hydrated ? "✅ Hydrated: buttons work" : "⏳ Static HTML: buttons do not work yet"}</p>
      <button onClick={() => setCount(count + 1)}>Add to cart ({count})</button>
    </div>
  );
}
`,
    "main.tsx": `import { hydrateRoot } from "react-dom/client";

const root = document.getElementById("root")!;

// 1. SERVER: make the HTML
const html = renderToString(<App />);

// 2. BROWSER: the HTML arrives and shows at once (no JavaScript yet)
root.innerHTML = html;
console.log("Server HTML shown. Try the button now: nothing happens.");

// 3. BROWSER: pretend the JavaScript takes 3 seconds to download
setTimeout(() => {
  hydrateRoot(root, <App />);
  console.log("Hydrated! The button works now.");
}, 3000);
`,
  }}
/>

### Hydrated React Applications

After hydration, the app is a normal React app. State, events, effects and client-side navigation all work. So SSR handles the **first** page, and CSR takes over after that.

One rule matters: the first browser render must make **the same HTML** as the server. If not, React reports a **hydration mismatch** and redraws that part. Values like `Math.random()`, `Date.now()` or `window.innerWidth` cause this. The lab shows one.

To show a browser-only value (the time, the screen size, a random tip), start with a fixed value, then change it in `useEffect`. Effects run only in the browser, after hydration, so the first HTML still matches.

## Data Fetching in SSR

### Server-Side Data Fetching

In CSR, the browser fetches data **after** the page loads, so users see "Loading...". In SSR, the server fetches data **before** rendering. The HTML already contains the data, so there is no loading state on the first view.

### Passing Data to Components

The server passes the data to the components as **props**, then renders. The browser needs the same data to hydrate, so the server also puts it in the page:

```html
<script>window.__INITIAL_DATA__ = {"posts":[...]}</script>
```

The browser reads it and hydrates with it. **No second fetch.**

<figure className="react-fig">
<svg viewBox="0 0 520 150" role="img" aria-labelledby="fig-ssr-data" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-data">Data in SSR: fetched on the server, passed as props, and sent along so the browser can hydrate without fetching again</title>
  <rect x="5" y="20" width="120" height="50" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="65.0" y="42.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Database / API</text>
  <text x="65.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>posts</text>
  <path d="M125 45 L160 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M160 45 L151.7 48.5 L151.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <text x="142.5" y="37.0" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontFamily: "var(--mono)" }}>fetch</text>
  <rect x="165" y="20" width="150" height="50" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="240.0" y="42.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Server</text>
  <text x="240.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>loadData()</text>
  <path d="M315 45 L350 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M350 45 L341.7 48.5 L341.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <text x="332.5" y="37.0" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontFamily: "var(--mono)" }}>props</text>
  <rect x="355" y="20" width="160" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="435.0" y="42.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>&lt;App initial=...&gt;</text>
  <text x="435.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>HTML with data</text>
  <rect x="165" y="95" width="350" height="40" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="340.0" y="120.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>window.&#95;&#95;INITIAL_DATA&#95;&#95; = &#123; posts: [...] &#125;</text>
  <path d="M240 70 L240 92" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M240 92 L236.5 83.7 L243.5 83.7 Z" style={{ fill: "var(--faint)" }} />
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="2.6s" repeatCount="indefinite" path="M125 45 L160 45 M315 45 L350 45" />
  </circle>
</svg>
<figcaption>The same data is used twice: to make the HTML, and to hydrate it.</figcaption>
</figure>

### Rendering Dynamic Content

**Dynamic content** changes from request to request: the newest posts, today's prices, the user's name. With SSR, every request gets HTML made with fresh data. The lab fetches posts "on the server", renders them, and hydrates with the same data.

      <h2>Latest posts</h2>
      <ul>
        {posts.map((p) => <li key={p.id}>{p.title + " by " + p.author}</li>)}
      </ul>
      <button onClick={() => console.log("Clicked: the page is interactive")}>Test a click</button>
    </div>
  );
}
`,
    "main.tsx": `import { hydrateRoot } from "react-dom/client";

async function serverRender() {
  // SERVER: get the data first
  const response = await fetch("https://api.bookcorner.dev/posts?delay=0");
  const posts = await response.json();

  // SERVER: pass the data as props, render HTML, send the data along
  const html = renderToString(<App posts={posts} />);
  return { html, data: posts };
}

async function start() {
  const { html, data } = await serverRender();
  const root = document.getElementById("root")!;

  // BROWSER: show the HTML, and keep the data the server sent
  root.innerHTML = html;
  (window as any).__INITIAL_DATA__ = data;
  console.log("HTML arrived with " + data.length + " posts in it");

  // BROWSER: hydrate with the SAME data, no second fetch
  hydrateRoot(root, <App posts={(window as any).__INITIAL_DATA__} />);
  console.log("Hydrated without fetching again");
}

start();
`,
  }}
/>

## Advantages and Limitations of SSR

<div className="tscroll">

| Advantages | Limitations |
|---|---|
| Fast first view, no blank page | Needs a Node.js server, not just static files |
| Full HTML for search engines and link previews | The server works on every request, so it costs more |
| Readable before JavaScript loads | Visible before clickable: a gap until hydration |
| Fresh data in every response | Code must run in Node and the browser (no `window` in render) |
| Better on slow phones | Hydration mismatches must be avoided |

</div>

Use SSR for public pages where the first view and search matter: blogs, shops, news. CSR is usually enough for apps behind a login.

<div className="tscroll">

| | CSR | SSR |
|---|---|---|
| First HTML | Empty `root` div | Full content |
| First view | After JS runs | Immediately |
| Interactive | After JS runs | After hydration |
| Hosting | Static files | Node.js server |

</div>

## Capstone Project: Blog Portal

You will build a small **Blog Portal** for Book Corner with SSR. It has a listing page, a details page for each post, server data fetching, dynamic routes, hydration, and loading and error states. The pretend API has posts at `https://api.bookcorner.dev/posts`.

<figure className="react-fig">
<svg viewBox="0 0 520 120" role="img" aria-labelledby="fig-ssr-blog" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssr-blog">Blog Portal routes: the list page and a dynamic details page, both rendered on the server</title>
  <rect x="10" y="30" width="150" height="52" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="85.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/</text>
  <text x="85.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>BlogListPage</text>
  <rect x="185" y="30" width="150" height="52" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="260.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/posts/:id</text>
  <text x="260.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>BlogPostPage</text>
  <rect x="360" y="30" width="150" height="52" rx="8" style={{ fill: "var(--bad-soft)", stroke: "var(--bad)", strokeWidth: 1.5 }} />
  <text x="435.0" y="53.0" textAnchor="middle" style={{ fill: "var(--bad)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>anything else</text>
  <text x="435.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>404 page</text>
  <rect x="10" y="30" width="150" height="52" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.5s" begin="0s" repeatCount="indefinite" />
  </rect>
  <rect x="185" y="30" width="150" height="52" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.5s" begin="1.5s" repeatCount="indefinite" />
  </rect>
  <rect x="360" y="30" width="150" height="52" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="4.5s" begin="3s" repeatCount="indefinite" />
  </rect>
  <text x="260" y="108" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>:id is a dynamic part: /posts/1, /posts/2, ...</text>
</svg>
<figcaption>One route pattern serves every post.</figcaption>
</figure>

### Create a blog listing page

`BlogListPage` gets the posts as a prop and shows a card for each: title (as a link), author, date and excerpt. It does not fetch anything: the data comes from the server.

### Create a blog details page

`BlogPostPage` gets one `post` and shows the full text, plus a `LikeButton`. The button has state and a click handler, so it only works after hydration.

The lab renders both pages on the "server" with sample data, so you can see their HTML.

  );

  const listHtml = renderToString(<BlogListPage posts={sample} renderLink={link} />);
  console.log("List page HTML: " + listHtml.length + " characters");
  const post = sample.find((p) => p.id === selected) ?? sample[0];

  return (
    <div className="blog">
      <hr />
    </div>
  );
}
`,
    "BlogListPage.tsx": `import type { Post } from "./types";

interface BlogListPageProps {
  posts: Post[];
  renderLink: (to: string, text: string) => JSX.Element;
}

  return (
    <section>
      <h2>Latest posts</h2>
      {posts.map((post) => (
        <article key={post.id} className="post-card">
          <h3>{renderLink("/posts/" + post.id, post.title)}</h3>
          <p className="meta">{post.author + " · " + post.date}</p>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </section>
  );
}
`,
    "BlogPostPage.tsx": `import type { Post } from "./types";

  return (
    <article className="post">
      <h2>{post.title}</h2>
      <p className="meta">{"By " + post.author + " · " + post.date}</p>
      <p>{post.body}</p>
    </article>
  );
}
`,
    "LikeButton.tsx": `import { useState } from "react";

// Works only after hydration: the server HTML has no click handler
  const [likes, setLikes] = useState(0);
  return (
    <button className="like" onClick={() => setLikes(likes + 1)}>
      {"♥ Like (" + likes + ")"}
    </button>
  );
}
`,
    "types.ts": `export interface Post {
  id: number;
  title: string;
  author: string;
  date: string;
  excerpt: string;
  body: string;
}

// Which page a URL points to
  | { page: "list" }
  | { page: "post"; id: number }
  | { page: "broken" }
  | { page: "notFound" };

// Everything a page needs to render, sent from the server to the browser
  status: number;
  posts?: Post[];
  post?: Post;
  error?: string;
}
`,
    "styles.css": `.blog { max-width: 560px; font-family: Georgia, serif; }
.blog header h1 { margin: 0 0 6px; font-size: 22px; }
.blog header a { color: #2b59c3; text-decoration: none; }
.blog nav { display: flex; gap: 10px; font-family: sans-serif; font-size: 13px; margin-bottom: 10px; }
.post-card { border-bottom: 1px solid #ddd; padding: 6px 0; }
.post-card h3 { margin: 0; font-size: 17px; }
.meta { color: #777; font-size: 13px; margin: 2px 0; font-family: sans-serif; }
.like { margin-top: 6px; padding: 5px 10px; border-radius: 6px; border: 1px solid #d33; background: white; color: #d33; cursor: pointer; }
.loading { color: #2b59c3; font-family: sans-serif; }
.error { color: #b42318; font-family: sans-serif; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

### Fetch content on the server

`routes.ts` has `loadData(route)`. The server calls it **before** rendering. It returns everything the page needs, called the **page data**: the `status`, plus `posts`, or one `post`, or an `error`.

### Implement dynamic routes

`matchRoute(url)` turns a URL into a route. The pattern `/^\/posts\/(\d+)$/` matches `/posts/` followed by digits. The digits are the **dynamic part**, the id. So one route serves every post: `/posts/1`, `/posts/2`, and so on. An unknown id gives status **404**.

In the lab, click the URLs to see the route, the status and the HTML the server makes for each.

        : data.post ? <BlogPostPage post={data.post} />
        : <BlogListPage posts={data.posts ?? []} renderLink={link} />;
      setResult("route: " + JSON.stringify(route) + "\\nstatus: " + data.status + "\\n\\n" + renderToString(page));
    }
    serve();
  }, [url]);

  return (
    <div>
      {["/", "/posts/1", "/posts/3", "/posts/99", "/broken"].map((u) => (
        <button key={u} onClick={() => setUrl(u)}>{u}</button>
      ))}
      <pre style={{ whiteSpace: "pre-wrap", background: "#f4f4f4", padding: 8 }}>{result || "..."}</pre>
    </div>
  );
}
`,
    "routes.ts": `import type { PageData, Route } from "./types";

const API = "https://api.bookcorner.dev";

// Dynamic routes: "/posts/2" matches the pattern /posts/:id
  if (url === "/") return { page: "list" };
  const match = /^\\/posts\\/(\\d+)$/.exec(url);
  if (match) return { page: "post", id: Number(match[1]) };
  if (url === "/broken") return { page: "broken" };
  return { page: "notFound" };
}

// Fetch the data for a route. The server calls this before rendering.
  const wait = "?delay=" + delay;
  try {
    if (route.page === "list") {
      const response = await fetch(API + "/posts" + wait);
      if (!response.ok) throw new Error("Status " + response.status);
      return { status: 200, posts: await response.json() };
    }
    if (route.page === "post") {
      const response = await fetch(API + "/posts/" + route.id + wait);
      if (response.status === 404) return { status: 404, error: "Sorry, this post does not exist." };
      if (!response.ok) throw new Error("Status " + response.status);
      return { status: 200, post: await response.json() };
    }
    if (route.page === "broken") {
      const response = await fetch(API + "/error" + wait);
      if (!response.ok) throw new Error("Status " + response.status);
    }
    return { status: 404, error: "Page not found." };
  } catch {
    return { status: 500, error: "Could not load this page. Please try again." };
  }
}
`,
    "BlogListPage.tsx": `import type { Post } from "./types";

interface BlogListPageProps {
  posts: Post[];
  renderLink: (to: string, text: string) => JSX.Element;
}

  return (
    <section>
      <h2>Latest posts</h2>
      {posts.map((post) => (
        <article key={post.id} className="post-card">
          <h3>{renderLink("/posts/" + post.id, post.title)}</h3>
          <p className="meta">{post.author + " · " + post.date}</p>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </section>
  );
}
`,
    "BlogPostPage.tsx": `import type { Post } from "./types";

  return (
    <article className="post">
      <h2>{post.title}</h2>
      <p className="meta">{"By " + post.author + " · " + post.date}</p>
      <p>{post.body}</p>
    </article>
  );
}
`,
    "LikeButton.tsx": `import { useState } from "react";

// Works only after hydration: the server HTML has no click handler
  const [likes, setLikes] = useState(0);
  return (
    <button className="like" onClick={() => setLikes(likes + 1)}>
      {"♥ Like (" + likes + ")"}
    </button>
  );
}
`,
    "types.ts": `export interface Post {
  id: number;
  title: string;
  author: string;
  date: string;
  excerpt: string;
  body: string;
}

// Which page a URL points to
  | { page: "list" }
  | { page: "post"; id: number }
  | { page: "broken" }
  | { page: "notFound" };

// Everything a page needs to render, sent from the server to the browser
  status: number;
  posts?: Post[];
  post?: Post;
  error?: string;
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

### Hydrate pages for client interaction

`server.tsx` puts the steps together: match the route, load the data, render `<App initial={data} />`. Then `main.tsx` shows that HTML, saves the data in `window.__INITIAL_DATA__`, and hydrates with **the same data**.

After hydration, `App` takes over in the browser. Clicking a link calls `navigate(url)`. It fetches the next page's data in the browser and renders it there, with no full page reload. This is the CSR part of an SSR app.

### Handle loading and error states

- **Loading:** during client navigation, `App` shows "Loading..." until the data arrives.
- **Not found:** an unknown post id gives status 404 and a friendly message.
- **Server error:** a failed request gives status 500 and "Could not load this page. Please try again."

Here is the finished Blog Portal. Open a post, press Like, and try **Missing post** and **Broken page**.

        {text}
      </a>
    );
  }

  let content;
  if (loading) content = <p className="loading">Loading...</p>;
  else if (page.error) content = <div className="error"><p>{page.error}</p>{renderLink("/", "Back to all posts")}</div>;
  else if (page.post) content = <BlogPostPage post={page.post} />;
  else content = <BlogListPage posts={page.posts ?? []} renderLink={renderLink} />;

  return (
    <div className="blog">
      <header>
        <h1>{renderLink("/", "Book Corner Blog")}</h1>
      </header>
      <nav>
        {renderLink("/", "All posts")}
        {renderLink("/posts/99", "Missing post")}
        {renderLink("/broken", "Broken page")}
      </nav>
      {content}
    </div>
  );
}
`,
    "main.tsx": `import { hydrateRoot } from "react-dom/client";

async function start() {
  // SERVER: the user asked for "/". Fetch, render and send.
  const { html, data, status } = await render("/");
  console.log("Server answered " + status + " with " + html.length + " characters of HTML");

  // BROWSER: show the HTML at once, and keep the data
  const root = document.getElementById("root")!;
  root.innerHTML = html;
  (window as any).__INITIAL_DATA__ = data;

  // BROWSER: hydrate with the same data, so clicks work
  hydrateRoot(root, <App initial={(window as any).__INITIAL_DATA__} />);
  console.log("Hydrated: links and the Like button work now");
}

start();
`,
    "server.tsx": `import { renderToString } from "react-dom/server";

// The pretend server: URL in, HTML + data + status out
  const route = matchRoute(url);
  const data = await loadData(route); // 1. fetch on the server
  const html = renderToString(<App initial={data} />); // 2. render to HTML
  return { html, data, status: data.status };
}
`,
    "routes.ts": `import type { PageData, Route } from "./types";

const API = "https://api.bookcorner.dev";

// Dynamic routes: "/posts/2" matches the pattern /posts/:id
  if (url === "/") return { page: "list" };
  const match = /^\\/posts\\/(\\d+)$/.exec(url);
  if (match) return { page: "post", id: Number(match[1]) };
  if (url === "/broken") return { page: "broken" };
  return { page: "notFound" };
}

// Fetch the data for a route. The server calls this before rendering.
  const wait = "?delay=" + delay;
  try {
    if (route.page === "list") {
      const response = await fetch(API + "/posts" + wait);
      if (!response.ok) throw new Error("Status " + response.status);
      return { status: 200, posts: await response.json() };
    }
    if (route.page === "post") {
      const response = await fetch(API + "/posts/" + route.id + wait);
      if (response.status === 404) return { status: 404, error: "Sorry, this post does not exist." };
      if (!response.ok) throw new Error("Status " + response.status);
      return { status: 200, post: await response.json() };
    }
    if (route.page === "broken") {
      const response = await fetch(API + "/error" + wait);
      if (!response.ok) throw new Error("Status " + response.status);
    }
    return { status: 404, error: "Page not found." };
  } catch {
    return { status: 500, error: "Could not load this page. Please try again." };
  }
}
`,
    "BlogListPage.tsx": `import type { Post } from "./types";

interface BlogListPageProps {
  posts: Post[];
  renderLink: (to: string, text: string) => JSX.Element;
}

  return (
    <section>
      <h2>Latest posts</h2>
      {posts.map((post) => (
        <article key={post.id} className="post-card">
          <h3>{renderLink("/posts/" + post.id, post.title)}</h3>
          <p className="meta">{post.author + " · " + post.date}</p>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </section>
  );
}
`,
    "BlogPostPage.tsx": `import type { Post } from "./types";

  return (
    <article className="post">
      <h2>{post.title}</h2>
      <p className="meta">{"By " + post.author + " · " + post.date}</p>
      <p>{post.body}</p>
    </article>
  );
}
`,
    "LikeButton.tsx": `import { useState } from "react";

// Works only after hydration: the server HTML has no click handler
  const [likes, setLikes] = useState(0);
  return (
    <button className="like" onClick={() => setLikes(likes + 1)}>
      {"♥ Like (" + likes + ")"}
    </button>
  );
}
`,
    "types.ts": `export interface Post {
  id: number;
  title: string;
  author: string;
  date: string;
  excerpt: string;
  body: string;
}

// Which page a URL points to
  | { page: "list" }
  | { page: "post"; id: number }
  | { page: "broken" }
  | { page: "notFound" };

// Everything a page needs to render, sent from the server to the browser
  status: number;
  posts?: Post[];
  post?: Post;
  error?: string;
}
`,
    "styles.css": `.blog { max-width: 560px; font-family: Georgia, serif; }
.blog header h1 { margin: 0 0 6px; font-size: 22px; }
.blog header a { color: #2b59c3; text-decoration: none; }
.blog nav { display: flex; gap: 10px; font-family: sans-serif; font-size: 13px; margin-bottom: 10px; }
.post-card { border-bottom: 1px solid #ddd; padding: 6px 0; }
.post-card h3 { margin: 0; font-size: 17px; }
.meta { color: #777; font-size: 13px; margin: 2px 0; font-family: sans-serif; }
.like { margin-top: 6px; padding: 5px 10px; border-radius: 6px; border: 1px solid #d33; background: white; color: #d33; cursor: pointer; }
.loading { color: #2b59c3; font-family: sans-serif; }
.error { color: #b42318; font-family: sans-serif; }
`,
  }}
/>

## Download Project Files

### Download SSR Project Source Code

The complete Blog Portal is ready as a real SSR project, built with **Vite**, **React**, **TypeScript**, **React Router** and a small **Express** server. It works like the final lab, but the server is real: view the page source in your browser and you will see the full HTML of the posts.

Download SSR Project: [Download Blog Portal Project](/downloads/react/ssr-blog-portal.zip)

```
blog-portal/
  package.json          scripts and dependencies
  server.js             Express server: SSR for pages + JSON API
  vite.config.ts        Vite settings
  index.html            template with <!--app-html--> and <!--app-data-->
  src/
    entry-server.tsx    render(url): fetch data + renderToString
    entry-client.tsx    hydrateRoot with window.__INITIAL_DATA__
    App.tsx             layout and routes
    routes.ts           route matching and loadPageData()
    pages/              BlogListPage, BlogPostPage, NotFoundPage
    components/         Header, PostCard, LikeButton, StatusMessage
    data/               posts.ts and postsApi.ts (the "database")
    hooks/              usePageData (server data first, then client fetch)
    types/              Post and PageData types
```

### Project Setup Instructions

1. Install **Node.js** 18 or newer from nodejs.org, if you do not have it.
2. Download the ZIP with the link above, and unzip it. You get a folder called `blog-portal`.
3. Open a terminal in that folder (in VS Code: File, Open Folder, then Terminal, New Terminal).
4. Install the packages and start the server:

```bash
npm install
npm run dev
```

Open `http://localhost:5173`. Press `Ctrl + C` to stop. For production, run `npm run build` and then `npm run start`.

To see SSR at work, open a post, then right-click and choose **View page source**. The post's text is in the HTML. In the CSR Product Catalog, the source showed only an empty `root` div.

## Common mistakes

<div className="tscroll">

| Mistake | What you see | Fix |
|---|---|---|
| Using `window` or `localStorage` during render | Server error: window is not defined | Use them in `useEffect`, which runs only in the browser |
| `Date.now()` or `Math.random()` in rendered output | Hydration mismatch in the console | Start with a fixed value, change it in `useEffect` |
| Using `createRoot` on server HTML | The page is drawn again and may flicker | Use `hydrateRoot` |
| Hydrating with different data than the server used | Mismatch errors, content jumps | Send the data in the page and hydrate with it |
| Fetching the same data again after hydration | An extra request and a loading flash | Use the server data for the first page |
| Expecting `useEffect` to run on the server | Missing data in the server HTML | Fetch data before `renderToString` |
| Returning 200 for a missing page | Search engines index error pages | Set status 404 when the data is not found |

</div>

## Check Yourself

## Exercises

## What to Remember

- In SSR, the server matches the route, fetches data, and uses `renderToString` to send full HTML. Users see content before the JavaScript loads.
- `hydrateRoot` attaches event handlers to that HTML. Hydrate with the same data the server used, and keep browser-only values in `useEffect`.
- SSR gives a fast first view and good SEO, but needs a server and has a gap before the page is clickable. After hydration, the app works like CSR.

Next: Static Site Generation
