TT Lab
Get started
Learn Learning paths Courses

SSR — The Server Draws First

Why Draw It on the Server

Continue in TT Lab

In one line

SSR means putting the content inside the first response bytes. The price is code that builds strings, and that is where XSS and hydration mismatches come from.

What is different

The first response of client-side rendering (CSR) looks like this.

<body><div id="root"></div><script src="/app.js"></script></body>

There is no content. The browser downloads the JS, runs it, calls the API, and only then does text appear. Until then the user sees a blank screen. And people are not the only ones who read that HTML.

SSR puts complete HTML in the first response. Then the JavaScript arrives and attaches only the events to the DOM that is already there — this is hydration.

It does not redraw; it puts handles on what is already drawn.

The problem this creates 1 — XSS

Server rendering is, in the end, string concatenation.

`<h1>${user.name}</h1>`

If user.name is <img src=x onerror=alert(1)>, it runs as is. You now have to do by hand what the frontend framework used to block for you.

The five characters you must replace when putting text into an HTML body.

&  →  &amp;      (먼저 바꿔야 한다)
<  →  &lt;
>  →  &gt;
"  →  &quot;
'  →  &#39;

If you do not replace & first, an &lt; you have already produced is broken again into &amp;lt;. The order is part of the rule.

And the rules differ by position. The HTML body, attribute values, URLs, JavaScript strings, and CSS all require different escaping. There is no single function that covers them all.

Problem 2 — when you plant the initial state

The server has already fetched the data, so the client does not need to call for it again. So you plant it in the HTML.

<script>window.__STATE__ = {"name":"...</script><script>alert(1)</script>"}</script>

If the data contains </script>, the script tag ends right there. Whatever follows is a new script. It may seem safe because it is JSON, but it is not at all.

How to prevent it.

JSON.stringify(state).replace(/</g, '\u003C')

Replacing < with a Unicode escape leaves the JSON value unchanged while the tag is no longer cut short. <!-- and <script are blocked along with it.

A safer approach is to put it in a tag that does not run.

<script type="application/json" id="state">{...}</script>

When type is application/json, the browser does not execute it. On the client you read it with JSON.parse(el.textContent). The </script> escaping is still needed even so.

Problem 3 — hydration mismatch

This is the hardest kind to find.

There is no reason for the HTML the server built and the HTML the client built first to differ. When they differ, the framework emits a warning, and in the worst case it redraws that part entirely.

The cause is almost always one of four.

  1. Time — new Date().toLocaleString(). The time drawn on the server and the time drawn on the client differ
  2. Random numbers — an id made with Math.random()
  3. Locale and time zone — the server is on UTC, the browser on KST
  4. Browser-only values — window.innerWidth, localStorage

A render function must be pure. The same input gives the same output.

When you need the time or a random number, create it on the server, put it in the state, and send it down. Do not call them inside the render function. In step 5 of this lab, the grader swaps out Date.now and Math.random and then renders twice and compares — if you call them inside the render, it shows up immediately.

Streaming SSR

If you build the whole page and then send it, the single slowest piece of data holds everything up.

[셸: <head>, 스타일, 상단바]  ← 즉시 보낼 수 있다
[본문: DB 조회 300ms]        ← 이것 때문에 셸까지 늦어진다

Streaming sends the shell first and then sends the ready pieces one after another. The browser parses as it receives, so it starts downloading CSS and fonts much earlier.

This is what React's renderToPipeableStream and <Suspense> do. It is the same principle you saw in the earlier SSE lab, and what blocks it is the same too — proxy buffering and gzip.

Cache — the most dangerous place in SSR

Personalized content easily gets mixed into an SSR page. The logged-in name, the cart count, a menu that differs by permission.

If Cache-Control: public is attached to that response, the CDN gives A's page to B. This really happens, and by the time it is discovered it is already too late.

익명·동일한 페이지   Cache-Control: public, s-maxage=60, stale-while-revalidate=300
로그인 사용자 페이지  Cache-Control: private, no-store

It is safer to make private, no-store the default and open up only what may be made public. If you do it the other way around, one day something will leak.

When SSR is not the answer

You do not need to SSR everything.

SSR costs server resources. The render runs on every request. Without a cache, traffic turns straight into CPU. The criterion for choosing is not "is it fast?" but "does the first HTML need to contain the content?"

In the field — three things to decide before turning SSR on

If you do not decide them together on the day you turn it on, they will bite you later without fail.

All three are decisions, not code. If you do not decide them in advance, you end up deciding on the spot on the day of the outage.