Rendering Patterns in React and Next.js — A Map for the Genuinely Confused
endering patterns are one of those topics where everyone nods along in standup and then quietly Googles “SSR vs SSG” for the fourth time that week.
I’ve been working with React and Next.js for a while now. And I’ll be honest — these concepts confused me for longer than I’d like to admit. Not because the ideas are hard, but because the industry has a habit of rebranding old ideas with new names, reversing its own opinions every three years, and selling each reversal as a prophecy.
When I started with React, CSR was the answer to everything. Multi-page apps were “legacy.” The Single Page Application was the future. Then SSR came back. Then static generation. Then server components. Then partial pre-rendering.
Image not OC
If you’ve been on this ride, you’re not confused because you’re slow. You’re confused because the goalposts kept moving and nobody gave you a proper map.
This is that map.
We’re going to build it the way I actually understood it — one question at a time, with honest answers.
The Baseline: What Actually Happens in a CSR App?
Let’s start with what most React developers learned first.
When a user visits a React SPA for the first time, here’s exactly what happens:
- The browser makes a request to the server
- The server responds with a near-empty
index.html— essentially just<div id="root"></div> - The browser downloads the JavaScript bundle (or multiple chunks if code-splitting is set up)
- The JS executes — React calls
root.render()and builds the DOM from scratch - If the app needs data,
useEffectfires, a fetch request goes out, and a loading spinner appears - Data arrives, React re-renders with actual content
- Dynamically injected images begin loading — this is your LCP event
That step 5 is the one worth staring at. The browser completed three full round trips before the user saw anything meaningful:
HTML loads → JS loads → JS executes → data fetch → content appearsThis is called the waterfall problem. It’s the original sin of pure CSR.
A note on terminology: When React “builds the DOM from scratch” in a CSR app, this is not called hydration. Hydration is a specific term for when React attaches event listeners to existing server-rendered HTML. In a pure CSR app, there’s no server-rendered HTML to hydrate. React is doing a full
root.render()— building everything from zero.
The Two Metrics You Need
Before going further, two performance metrics matter here:
FCP (First Contentful Paint): When the user first sees anything — a heading, a nav, any pixel that isn’t a blank white screen.
TTI (Time to Interactive): When the page actually responds to user input within 50ms. A page can look complete and still not be interactive if the main thread is busy executing JavaScript.
DOMContentLoadedFires when the browser has finished parsing the HTML and built the DOM tree. CSS and images may still be loading. JS that'sdefered has also finished running by this point. It does not mean the page looks good or is interactive.
loadFires when everything is done — images, stylesheets, fonts, subresources. This is whatwindow.onloaduses.
In a CSR app, DOMContentLoaded fires almost immediately — on the empty div. But TTI comes much later, after the entire JS bundle has downloaded, parsed, and executed. The gap between these two is the "SPA tax." On a slow mobile connection with a large bundle, this gap can be 5–8 seconds.
DOMContentLoaded (empty div, useless) → JS downloads + executes → React renders → TTI (actually interactive) → load (images etc done)SSR: What the Server Actually Sends
Server-Side Rendering flips the model. Instead of sending an empty shell, the server:
- Receives the request
- Fetches the data it needs
- Renders complete HTML with that data already baked in
- Sends finished HTML to the browser
The browser receives real content and can paint immediately. FCP is fast. SEO crawlers are happy.
But here’s what most tutorials gloss over: the page looks interactive before it is.
After the HTML renders, the browser still downloads and executes the JavaScript bundle. React then “hydrates” the page — walks the entire DOM tree, reconstructs its Virtual DOM, and attaches event listeners to each node. During this entire process, buttons look clickable. They aren’t.
This gap between “looks ready” and “is ready” is called the hydration gap.
What happens if you click during the hydration gap?
Three outcomes, depending on the element:
- Dropped — React hasn’t attached a listener yet. The click fires into the void.
- Replayed — React captures the event, finishes hydrating, then fires the handler.
- Double-fired — a native browser default (form submit, anchor navigation) catches it, and React’s synthetic handler fires after hydration. You get the action twice.
Wait — Isn’t Next.js Still an SPA?
After learning SSR, most people assume Next.js works like a traditional multi-page app — every link click goes to the server, which responds with a fresh HTML document, causing a full page reload.
It doesn’t. And this is the part that took me an embarrassingly long time to properly understand.
When you use Next.js’s <Link> component, there is no browser navigation. No request for a new HTML document. No full page reload. The <Link> component intercepts the click and handles it with JavaScript — exactly like a SPA would.
You can verify this yourself: open DevTools, go to the Network tab, and click a <Link>. Filter by Fetch/XHR. You'll see a request — but it returns a small payload, not a full HTML document.
So what’s Next.js actually doing on navigation?
The first time you visit a Next.js site, you get full SSR HTML — great FCP, data already baked in. But the moment that page loads, the Next.js router takes over. From that point on:
- You click a
<Link> - Next.js intercepts it — no browser navigation
- It fetches only the data/structure for the new page (more on the format shortly)
- It patches only the changed part of the DOM
- The URL updates via the browser’s History API
The browser never wipes memory and starts over. Which means — if you have a <Header> and <Footer> in your layout.js, they literally never get destroyed when you navigate. Same DOM nodes. Any state inside them (a search input's current value, a dropdown's open state) survives the navigation.
First load: Server → Full HTML → fast FCP → JS hydratesSubsequent: JS intercepts click → fetches new page data → patches DOM → no reloadThis is the hybrid. SSR for the first load, SPA behaviour for everything after. The industry calls this “Universal” or “Isomorphic” rendering — the same app renders on the server initially and then takes over on the client.
Next 16 extends this further. Instead of unmounting the route you’re leaving, it wraps it in React’s <Activity> component in hidden mode — so component state in the page you navigated away from survives too.
The
<a>tag trap: If you use a plain<a href="/about">instead of<Link href="/about">, you lose all of this. The browser treats it as a real navigation, wipes the page, and requests fresh HTML from the server. Your layout gets destroyed and rebuilt. This is why headers "flicker" or reset in some Next.js apps — someone used an anchor tag where they should have used Link.
Layouts vs Templates
Next.js makes this persistence explicit:
Use layout.js for things that should survive navigation — navigation bars, audio players, shopping cart state, theme toggles. Use template.js only when you explicitly want a fresh mount on every route change (like per-page entrance animations).
So is Next.js an SPA?
In behaviour — yes. In architecture — it’s a hybrid. The correct answer depends on which question you’re asking:
- “Does it do full page reloads?” → No. SPA behaviour.
- “Does the server do rendering work?” → Yes, on first load and for data. Not purely client-rendered.
- “Is the JavaScript bundle the only thing the server sends?” → No. First load is real HTML.
Don’t get too attached to the label. What matters is understanding the two modes: SSR on first hit, SPA on every subsequent click.
SSG: Do the Work Once
Static Site Generation takes SSR’s idea further. Instead of rendering HTML on every request, you render it once at build time and store the result as flat HTML files served from a CDN.
No server processes your request. Response is instant, infinitely scalable, impossible to crash with traffic.
When SSG is right:
- Marketing pages, blog posts, documentation
- Anything where content doesn’t change per user or per minute
When SSG is wrong:
- 10,000 product pages (build time becomes painful)
- Data that changes frequently
- Anything personalised per user
ISR: Static Speed, Periodic Freshness
Incremental Static Regeneration sits between SSG and SSR. You get static-file delivery speed, but the page refreshes after a time window you control.
The important thing to understand is the exact model — called stale-while-revalidate:
User A hits /product at 12:00 → cache cold → server renders → caches → serves itUser B hits /product at 12:00:45 → within window → served from cache (stale, fast)User C hits /product at 12:01:05 → past window → served stale immediately → background rebuild triggeredUser D hits /product at 12:01:10 → gets the freshly rebuilt pageNotice: User C gets stale data. ISR doesn’t guarantee freshness at the revalidation boundary. It guarantees freshness for the next request after revalidation completes.
This is why time-based ISR is often the wrong tool. If your data changes when an admin publishes, use on-demand ISR instead:
// Call this from a CMS webhook or Server Action after a content updaterevalidatePath('/blog')revalidateTag('posts','max')What ISR is genuinely wrong for:
Streaming SSR: Don’t Wait for the Slowest Thing
Regular SSR is all-or-nothing. If your page has three data sources and one is slow, the server waits for all three before sending a single byte.
Streaming SSR breaks this.
With <Suspense>, you mark parts of the component tree as "this can wait." The server immediately flushes everything outside the Suspense boundary as chunk one. The browser starts rendering the shell and downloading JS. When the slow data arrives, the server sends chunk two — the rendered component HTML plus a tiny inline script telling React which fallback to replace.
export default function Dashboard() { return ( <main> <Header /> {/* renders immediately, sent in chunk 1 */} <Suspense fallback={<Skeleton />}> <SlowWidget /> {/* awaits data, sent in chunk 2 when ready */} </Suspense> </main> )}This uses HTTP chunked transfer encoding — not a new protocol. It’s existed since HTTP/1.1. The browser’s HTML parser is incremental by design. It renders what it receives, not what it’s waiting for.
What is
<Suspense>actually doing mechanically?
It’s a signal to the server: “this is a safe boundary to split the response.” Everything outside can be flushed now. Everything inside arrives later, as a separate chunk, over the same open HTTP connection.
Selective Hydration works alongside this. React hydrates each Suspense boundary independently as its chunk lands — not the whole page at once. If you click an area that hasn’t hydrated yet, React jumps the queue, hydrates that boundary first, and replays your click.
React Server Components: The Bundle Size Story
This is the one that trips most React developers up, because it sounds like “component-level SSR.” It isn’t.
SSR produces an HTML string. The browser renders it immediately (fast FCP), then downloads the entire JS bundle to reconstruct React’s Virtual DOM and hydrate. Even a completely static <Footer> — one that never changes, has no event handlers — still requires its JS shipped to the browser so React can "account for" it during hydration.
RSC changes the output format.
Instead of HTML, the server produces an RSC Payload — a serialized description of the component tree. Schematically, it looks something like:
["$","h1",null,{"children":"Hello World"}]Server Components never send their JavaScript to the browser. Zero kilobytes. If your <ProductList> uses a 200KB library, that library stays on the server. Only the rendered output crosses the network.
On first load: Next.js sends both — HTML for fast FCP and RSC payload for React to hydrate correctly. They serve different consumers. HTML is for the browser’s native renderer. RSC payload is for React’s reconciler.
On subsequent <Link> navigations: Only the RSC payload is sent. The shell is already on screen. React patches only what changed — which is why your layout (header, footer, sidebar) never re-renders when you navigate between pages. This is what makes Next.js an SPA in behaviour despite doing server rendering.
The most common mistake in Next.js codebases: Putting
'use client'at the top of a page component because you need oneuseStatesomewhere inside it. This ships the entire page's JS to the browser and loses all RSC benefits. The fix: keep the page as a Server Component, extract only the interactive widget into its own Client Component.
Static vs Dynamic RSC: Where SSG Lives Now
In the App Router, you don’t “choose RSC or SSG.” You write an RSC (the component type) and decide the data strategy — when it runs and whether it’s cached.
// Static RSC — opt in explicitly.// Dynamic RSC - the default. Runs on every request.Next.js 16 inverted this. Under cacheComponents, nothing is cached by default — every page, layout and route handler runs at request time unless you explicitly opt in with the use cache directive.
The old model (cached by default, made dynamic by touching cookies(), headers() or searchParams). If you learned that model, unlearn it.
The mental model:
RSC = the component type (server-only execution)SSG/SSR = the delivery strategy (when does it run, is it cached?)Putting It Together: A Real Scenario
A job board with four distinct requirements. What rendering strategy for each?
Homepage — featured jobs, updated a few times a day by an admin → ISR with on-demand revalidation. Same content for all users, changes infrequently. Trigger revalidatePath('/') from your CMS webhook. Static speed, fresh when it matters.
Job detail pages — 10,000 individual listings → ISR, not SSG. Building 10,000 pages at deploy time is expensive. ISR generates each page on first request and caches it. Use generateStaticParams to pre-build only your top 100 most-visited listings at build time. The rest get ISR on-demand.
“My Applications” page — per-user, auth-gated → SSR (dynamic rendering). Personalised and auth-gated. ISR would cache one user’s data and serve it to the next person who visits that route. Must be dynamic, must check the session on every request.
“Who’s viewing this job” counter — live, real-time → CSR, inside the ISR page. The job detail page is ISR. The counter is a 'use client' component that mounts after hydration and polls or connects via WebSocket. The page loads at static speed; the counter populates shortly after.
The job detail page uses two strategies simultaneously — ISR for the shell, CSR for the live counter. This is the key insight: rendering strategies are component-level decisions, not page-level ones.
The Decision Map
Your tree still reads as though SSG / ISR / PPR / SSR are four modes you select. Under 16 they’re emergent from two decisions. Add beneath the existing tree, don’t replace it:
In Next 16, you don’t pick a mode. You make two choices:
1. Where does cache go? → decides what’s static
2. Where do Suspense boundaries go? → decides what streams
Everything above is a name for a combination of those two answers.
The Honest Summary
The industry spent the 2010s moving everything to the client. It spent the 2020s moving it back to the server — but smarter, with component-level granularity instead of page-level all-or-nothing.
Pattern You gain You give up CSR Simplicity, interactivity FCP, SEO, waterfall problem SSR Fast FCP, fresh data Hydration gap, server cost per request SSG Speed, infinite scalability Data freshness, long builds at scale ISR Static speed + periodic freshness Guaranteed freshness at revalidation boundary RSC Smaller JS bundle, no hydration cost for static parts No useState, no browser APIs Streaming SSR Progressive rendering, better TTFB Complexity, Suspense boundary planning PPR Static + dynamic on the same page without manual architecture.Default behaviour in 16; you now decide cache boundaries explicitly
None of these patterns are universally correct. Each is a tradeoff.
The goal isn’t to pick one and defend it. The goal is to understand the tradeoffs well enough to compose them correctly — per page, per component, per use case.
If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.
Image not OC
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.