DEV Community

Akash Kumawat
Akash Kumawat

Posted on

Who Actually Builds Your HTML? A Field Guide to CSR, SSR, SSG, ISR and Server Components 🏗️

You've been shipping React for a while. Components, hooks, state, the whole thing. Then someone in a code review drops a casual "is this bit SSR or CSR?" and you nod like you know, then quietly open a new tab.

Been there. 😅

The problem isn't that these concepts are hard — it's that they get explained as a list of acronyms when they're really answers to the same small set of questions. So let's throw out the list and start with the question.


🧐 The One Question That Explains All of It

Every rendering strategy is an answer to this:

When and where does your HTML get built?

That's it. Three possible "whens" — at build time, at request time, or in the browser — and two possible "wheres" — your server or the user's device. Every acronym you've been squinting at is just a combination of those.

Once that clicks, server components vs client components turns out to be a different question entirely (we'll get there), and half the confusion disappears.


🎨 CSR: The Empty Div

Client-Side Rendering is what you get out of the box with Vite, Create React App, or any plain React setup. The server sends the browser this:

<body>
  <div id="root"></div>
  <script src="/bundle.js"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

That's your entire page. An empty div and a promise. The browser downloads the JavaScript, runs it, React builds the DOM, and then the user sees something.

Think of it like ordering food and getting a bag of raw ingredients plus a recipe card. Everything you need is in there! You just have to do the cooking.

What's good about it: The server does almost nothing, so it's cheap and easy to host. Once the app boots, navigating between pages is instant — no round trips, just state changes. It's a genuinely great fit for dashboards, editors, and anything behind a login.

What hurts: The user stares at a blank screen until the bundle downloads and parses and runs. On a slow phone on slow data, that's painful. And because the initial HTML is empty, anything that reads your page without running JavaScript — search crawlers, link preview bots, some accessibility tooling — sees nothing.


🍳 SSR: The Kitchen Cooks It For You

Server-Side Rendering flips it. On every request, your server runs your components, produces real HTML, and sends that:

<body>
  <div id="root">
    <h1>Hello, Akash</h1>
    <p>You have 3 new messages</p>
  </div>
  <script src="/bundle.js"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

The user sees content almost immediately. The meal arrives cooked.

But look at that <script> tag — it's still there. The HTML is just a picture. Buttons don't click, inputs don't type, nothing is wired up. The browser still downloads the same bundle, runs React, and React walks the existing DOM attaching event listeners to it.

That process is called hydration, and it's the part everyone skips.

So the meal arrives hot, but the cutlery is delivered separately and you can't actually eat until it shows up. There's a real window where your page looks ready and isn't. Users notice — they click, nothing happens, they click again.

What's good: Fast first paint, fresh data on every request, crawlers get real content.

What hurts: Your server does work on every single request, so it costs more and it's slower to start responding (the server has to fetch data and render before it can send anything). And you've now got a gap between "looks interactive" and "is interactive."


🥡 SSG: Sunday Meal Prep

Static Site Generation builds the HTML once, at build time, and serves the same file to everyone.

// Next.js — no dynamic functions, no per-request data
export default async function BlogPost({ params }) {
  const post = await getPost(params.slug); // runs at build
  return <article>{post.body}</article>;
}
Enter fullscreen mode Exit fullscreen mode

You run next build, it generates one HTML file per blog post, those files go on a CDN, done. The server does zero work per request because there is no per-request work — it's just handing over a file that already exists.

This is unbeatable on speed. Nothing renders faster than a file sitting on a CDN twenty kilometres from the user.

The catch: that file is frozen at build time. Fix a typo? Rebuild. Prices changed? Rebuild. Got 50,000 product pages? Enjoy your 40-minute build. 🙃

Perfect for blogs, docs, marketing pages, changelogs. Anything where the content changes on your schedule, not the user's.


🥖 ISR: The Bakery Model

Incremental Static Regeneration is SSG that admits the real world exists.

The idea: serve the cached static page instantly, but if it's older than some threshold, quietly bake a fresh one in the background for the next person.

// Next.js App Router
export const revalidate = 60; // seconds
Enter fullscreen mode Exit fullscreen mode

Here's the sequence with a 60-second window:

  1. First request → page is generated, cached, served.
  2. Requests within 60s → served from cache. Instant.
  3. Request at 61s → still served the stale cached page (instantly), and a regeneration kicks off in the background.
  4. Next request → gets the fresh one. Nobody ever waits for the rebuild. Somebody just gets slightly old bread. That trade — "instant but maybe a minute stale" — is exactly right for e-commerce listings, news feeds, leaderboards, anything high-traffic where a 60-second delay is invisible to users but a full SSR bill would hurt.

Most frameworks also let you trigger regeneration on demand, so you don't have to guess a number:

// Someone edited a post in your CMS → webhook hits this
revalidatePath('/blog/my-post');
Enter fullscreen mode Exit fullscreen mode

Now it's static and fresh the moment content actually changes. Best of both, honestly. This is the one I'd reach for by default on any content-driven site.


📊 The Cheat Sheet

HTML built Per-request server work First paint Data freshness
CSR In the browser None Slowest Fresh (after fetch)
SSR On the server, per request Every request Fast Always fresh
SSG At build time None Fastest Stale until rebuild
ISR Build time + background refresh Occasional Fastest Stale by up to N seconds

And the thing that actually matters: these are per-route, not per-app. Your marketing page can be SSG, your product listing ISR, your dashboard CSR, your checkout SSR. Modern frameworks let you mix all four in one codebase, and you should.


🧩 Now The Other Axis: Server vs Client Components

Here's where people get tangled, so let's be blunt about it:

SSR vs CSR is about where HTML is generated. Server vs Client Components is about where your component's code lives.

Different questions. You can have both at once. A React Server Component is not "a component that gets SSR'd" — plenty of client components get SSR'd too.

What a Server Component actually does

A Server Component runs on the server and its JavaScript never reaches the browser at all.

// No 'use client' — this is a Server Component by default (App Router)
import { marked } from 'marked'; // a chunky markdown library

export default async function Post({ id }) {
  const post = await db.posts.find(id); // talk to the DB directly
  return <article dangerouslySetInnerHTML={{ __html: marked(post.body) }} />;
}
Enter fullscreen mode Exit fullscreen mode

Two things just happened that are genuinely new:

  1. You queried your database inside a component. No API route, no useEffect, no loading state.
  2. That marked library — all 40-ish KB of it — never ships to the user. It ran on the server and only the resulting HTML crossed the wire. That second point is the whole pitch. With SSR, your bundle contains every component on the page. With Server Components, it contains only the interactive ones.

What Client Components actually are

'use client';

import { useState } from 'react';

export default function LikeButton({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(count + 1)}>❤️ {count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

'use client' is the thing everyone misreads. It does not mean "render this only in the browser." This component still gets rendered on the server into HTML, same as always — it just also ships its JavaScript so it can hydrate and become interactive.

So the label is really: "this component needs to exist on the client too."

You need it whenever you use:

  • useState, useEffect, useReducer, or any hook with state
  • Event handlers — onClick, onChange, onSubmit
  • Browser-only APIs — window, localStorage, IntersectionObserver
  • Most third-party UI libraries (they're full of the above) ### It's a boundary, not a label

This is the part worth tattooing somewhere:

'use client' marks a boundary. Everything imported from a client component becomes a client component too.

Put 'use client' at the top of your root layout and congratulations — you've just opted your entire app back into the old model. 😬

The instinct is to fight this by hoisting 'use client' upward. Do the opposite: push it down to the leaves. Don't mark the whole page as a client component because there's one button on it. Mark the button.

And when a client component needs to wrap server content, pass it through as children rather than importing it:

// ✅ Accordion is a client component, but Post stays on the server
<Accordion>
  <Post id={1} />
</Accordion>
Enter fullscreen mode Exit fullscreen mode

The <Post /> element is created on the server and handed to Accordion as an already-rendered child. Accordion never imports it, so it never drags it into the client bundle. This pattern will save you more kilobytes than any other single thing you do.

The rules you'll trip over

Props crossing from server → client have to be serializable. Strings, numbers, arrays, objects, dates — fine. Functions, class instances, Symbol — not fine, and you'll get an error that takes a minute to decode.

// ❌ Can't send a function across the boundary
<ClientThing onSave={(data) => db.save(data)} />
Enter fullscreen mode Exit fullscreen mode

The escape hatch is Server Actions, which are functions the server exposes and the client calls by reference — but that's a whole post of its own.


😱 Pitfalls: The Five Everyone Hits

1. Thinking 'use client' means client-only. It doesn't. It means "also on the client." Your component still renders on the server, so window is not defined will still bite you at the top level.

2. Slapping 'use client' on the root layout. One directive, entire app back to full hydration. Push it to the leaves.

3. Assuming SSR is automatically faster. SSR improves first paint but delays time-to-first-byte (server has to fetch and render first). For an app that's already behind a login screen, CSR is often the better call.

4. Ignoring the hydration gap. The window where the page looks ready but isn't is real, and it's where confused double-clicks live. Streaming and Suspense boundaries are how you shrink it.

5. Picking one strategy for the whole app. This is the big one. These are route-level decisions. Mix them.


🚀 So What Do You Actually Pick?

Rough heuristics that hold up well:

  • Content that rarely changes (blog, docs, landing) → SSG
  • Content that changes but not per-user (product pages, feeds) → ISR
  • Personalized or always-fresh (dashboards with server data, search results) → SSR
  • Heavily interactive, behind auth, SEO irrelevant (editors, admin panels) → CSR is fine, genuinely
  • Components → server by default, client only where you need interaction, boundary as low as possible And the same advice I'd give about any optimization: measure before you commit. Ship it, look at your actual TTFB and FCP and bundle size, then decide. The right answer for a Bengaluru user on a mid-range Android and a reviewer on a MacBook are not always the same answer. 📱

🎉 Wrapping Up

Strip the acronyms away and there are only two questions:

  1. When does the HTML get built? → build time (SSG), build time + refresh (ISR), per request (SSR), or in the browser (CSR)
  2. Does this component's code need to ship to the browser? → no (server component) or yes (client component) Everything else is detail. And the nice part is you don't have to pick one and live with it — pick per route, pick per component, and change your mind when the numbers tell you to.

Happy Coding :))

Top comments (0)