Why Draw It on the Server
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.
- Search engines and link previews — many crawlers do not run JavaScript. If the OG tags and the body are not in the first HTML, they do not exist
- Slow devices and networks — everything until the JS bundle arrives and runs is a blank screen
- If the JS fails — nothing appears at all
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.
& → & (먼저 바꿔야 한다)
< → <
> → >
" → "
' → '
If you do not replace & first, an < you have already produced is broken again into &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.
- Time —
new Date().toLocaleString(). The time drawn on the server and the time drawn on the client differ - Random numbers — an id made with
Math.random() - Locale and time zone — the server is on UTC, the browser on KST
- 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.
- Static generation (SSG) — if the content rarely changes, build it ahead of time. It is the cheapest and fastest
- CSR — screens with no SEO need and a lot of interaction, such as a dashboard after login
- SSR — screens whose content differs on every request and must be in the first HTML
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.
- Cache key — does the response differ per user? If so,
private, no-store; if it is the same for everyone,s-maxage. When in doubt, start closed - APIs that do not exist on the server — if code that references
window,localStorage, ordocumentgets mixed into the server bundle, the whole page dies. Filter it once at the entry point and check it in CI - When the data is late — do you hold up the whole page, or leave just that part empty and fill it in on the client?
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.