React Rendering Strategies, Explained: CSR, SSR, SSG, ISR, and PPR
CSR, SSR, SSG, ISR, and PPR explained with realistic examples, plus exactly which Core Web Vitals each rendering strategy moves. From build time to the browser.
Every modern web app has to answer one deceptively simple question: where, and when, does my HTML get built? In the browser after JavaScript loads? On a server the moment a request arrives? Once, ahead of time, during a build? The answer you pick shapes how fast your pages feel, how fresh your data is, how much your servers cost, and how well search engines see your content. This guide walks through every major rendering strategy (CSR, SSR, SSG, ISR), the stale-while-revalidate pattern that ties several of them together, the newer Server vs Client Components split that sits on top of all of it, the 2026 synthesis of Partial Prerendering and Cache Components, and finally exactly which Core Web Vitals each strategy moves. Plain-English first, then realistic examples, then the tradeoffs that actually bite in production.
How to read this guide. There are really two separate questions hiding in here, and most confusion comes from mixing them up. The first is when the HTML is produced (CSR, SSR, SSG, ISR). The second is where your component code runs (Server Components vs Client Components). We tackle the timeline first, because it is the older and more intuitive axis, then layer Server Components on top once the foundation is solid.
One Mental Model: Three Moments in Time
Before naming any strategy, internalize this. Work on a web page can happen at exactly three moments:
- Build time is when you run
npm run buildand ship the result. Work done here happens once, for everyone, before a single user shows up. - Request time is the instant a user's browser asks your server for a page. Work done here happens fresh, per request, on the server.
- Client time (runtime) is after the page reaches the browser and JavaScript starts executing on the user's device.
Analogy. Think of a bakery. Build time is baking a giant batch of bread overnight so it is ready the moment you open. Request time is baking a loaf to order when a customer walks in. Client time is handing the customer raw ingredients and a recipe so they bake it themselves at home. Each has obvious tradeoffs: the overnight batch is instant but can go stale, baking to order is always fresh but slower, and sending ingredients home is cheapest for the bakery but the most work for the customer.
Every rendering strategy is just a different choice about which moment does the work. Hold onto that. CSR pushes work to client time, SSR to request time, SSG to build time, and ISR is a clever blend of build time and request time. Once you see them as points on this timeline, the names stop being jargon.
Client-Side Rendering (CSR)
CSR is the classic single-page application approach. The server sends a nearly empty HTML shell plus a big JavaScript bundle. The browser downloads and runs that JavaScript, which then builds the entire UI, fetches data, and fills in the page. Nothing meaningful is visible until the JavaScript has loaded and executed.
Analogy. CSR is flat-pack furniture. The box that arrives (the HTML) is small and light, but it is just panels and a bag of screws. The customer (the browser) does all the assembly. Fast to ship, but the customer stares at a pile of parts for a while before anything looks like a chair.
Here is what the server actually sends with a plain CSR app. Notice the body is essentially empty:
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<div id="root"></div> <!-- empty: React fills this in -->
<script src="/bundle.js"></script> <!-- the whole app lives in here -->
</body>
</html>And the React entry point that takes over once that script runs:
import { createRoot } from "react-dom/client";
import App from "./App";
// Runs in the browser, after the bundle downloads and parses
createRoot(document.getElementById("root")).render(<App />);
// App then fetches its own data, client-side, after mounting:
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/metrics").then(r => r.json()).then(setData);
}, []);
if (!data) return <Spinner />; // user sees this first, every visit
return <Charts data={data} />;
}What CSR is good at. Highly interactive apps that live behind a login, where SEO does not matter and the user will stick around for a long session: dashboards, admin panels, design tools, internal apps. Once the bundle is loaded, navigation between views is instant because it is all happening in the browser with no further full-page loads.
Where CSR hurts. The first load is the weak point. The user waits through three serial steps: download the HTML shell, download and parse the JavaScript, then wait for the data fetch to resolve. On a slow phone or flaky connection, that is a long blank screen. And because the initial HTML is empty, search engine crawlers and link-preview bots may see nothing useful.
Gotcha. "But Google runs JavaScript now." It does, sometimes, with a delay and a rendering budget. Relying on it for content you care about ranking is a gamble. Social preview crawlers (the bots behind link unfurls in chat apps and social posts) typically do not run your JavaScript, so a CSR-only page often unfurls as a blank card.
Server-Side Rendering (SSR)
SSR moves the rendering to request time on the server. When a request arrives, the server runs your components, fetches the data they need, produces a complete HTML string, and sends that fully-formed page to the browser. The user sees real content almost immediately, before any of your JavaScript has run.
But there is a second half: hydration. The static HTML the server sent is not yet interactive. The browser still downloads your JavaScript, and React "attaches" to the existing markup, wiring up event handlers and state so buttons and forms start working. The page looks ready before it actually is ready to click.
Analogy. SSR is a fully assembled chair delivered to your door, with a note that says "the wheels lock once the delivery person flips a switch." You can see and admire the chair right away (the HTML), but you cannot roll it around until that switch is flipped (hydration). The gap between looks ready and is ready is the part SSR newcomers underestimate.
A minimal SSR flow, conceptually:
// On the server, per request:
import { renderToString } from "react-dom/server";
app.get("/product/:id", async (req, res) => {
const product = await db.products.find(req.params.id); // fresh data, now
const html = renderToString(<ProductPage product={product} />);
res.send(`
<div id="root">${html}</div> <!-- real content, immediately -->
<script src="/bundle.js"></script> <!-- hydrates the markup above -->
`);
});// On the client, the same component re-attaches to that HTML:
import { hydrateRoot } from "react-dom/client";
hydrateRoot(document.getElementById("root"), <ProductPage product={preloaded} />);What SSR is good at. Content that must be fresh on every request and needs to be visible to crawlers or fast on first paint: a logged-in account page showing live balances, a search results page, a news homepage during a breaking event, a personalized feed. You get good SEO and a fast first contentful paint without giving up per-request freshness.
Where SSR hurts. Every request does real work on your server, so it costs more compute and adds latency (the server has to fetch data and render before it can respond). Under heavy traffic, an expensive SSR route can become a bottleneck. There is also the "uncanny valley" of hydration, where the page is visible but momentarily unclickable.
Gotcha: hydration mismatches. The HTML the server produced must match what React produces on the client during hydration. If they differ (a common cause is renderingnew Date(),Math.random(), orwindow-dependent values during render), React throws a hydration error and may discard the server HTML. The fix is to make render output deterministic and push anything browser-specific into an effect that runs only after mount.
Static Site Generation (SSG)
SSG does the rendering at build time. When you build the app, the framework runs every page, fetches the data each one needs, and writes out finished HTML files. At request time the server (or, more commonly, a CDN) just hands over a pre-built file. No rendering, no data fetching, no waiting. It is about as fast as the web gets.
Analogy. SSG is the overnight bread batch from our bakery. Everything is baked before opening, so when a customer walks in, you just hand them a loaf off the shelf. Instant. The catch is obvious: whatever you baked last night is what they get, even if the recipe changed this morning.
A typical SSG page in the Next.js App Router. With no dynamic data APIs in play, this page is rendered once at build and served as a static file forever:
// app/about/page.tsx
// No request-time data, no cookies/headers: Next prerenders this at build.
export default function About() {
return (
<main>
<h1>About Us</h1>
<p>We have been building things since 2019.</p>
</main>
);
}For pages that depend on a dataset known at build time, like one page per blog post, you tell the framework which paths exist so it can generate them all:
// app/blog/[slug]/page.tsx
// Runs at BUILD time: returns the full list of slugs to prerender.
export async function generateStaticParams() {
const posts = await getAllPosts(); // hit the CMS once, at build
return posts.map(post => ({ slug: post.slug }));
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug); // also at build, per slug
return <article>{post.body}</article>;
}What SSG is good at. Content that is the same for every visitor and changes rarely: marketing pages, documentation, blog posts, changelogs, legal pages. Because the output is a plain file on a CDN, it is cheap, scales to enormous traffic effortlessly, and is perfectly crawlable.
Where SSG hurts. Two real limits. First, freshness: the content is frozen at build time, so anything that changed since the last build is stale until you rebuild. Second, build duration: if you have hundreds of thousands of pages, generating every one at build time can make builds painfully slow. A site with a million product pages cannot realistically rebuild all of them every time one price changes. That exact pain is what ISR was invented to solve.
Incremental Static Regeneration (ISR)
ISR is the bridge between SSG's speed and SSR's freshness. The idea: serve a static page like SSG, but let it regenerate in the background on a schedule or on demand, without a full rebuild. You get the CDN-speed of static files and the ability to update content, page by page, while the site is live.
Here is the actual mechanism, because the details matter. You attach a revalidate window to a page. The first request serves the prebuilt page. After the window expires, the next request still gets the cached (now stale) page instantly, and that request quietly triggers a background regeneration. Once the new version finishes, it replaces the cached one, so the request after that gets fresh content. Crucially, no user ever waits for the regeneration; someone just gets one slightly-stale response so everyone after them gets a fresh, fast one.
Analogy. ISR is a bakery that restocks the shelf in the background. When a customer takes the last loaf from this morning's batch, that very act signals the kitchen to bake a fresh batch. The customer who took the last loaf still got this morning's bread (slightly stale, but instant), and the next customer gets the warm new batch. Nobody stands at the counter waiting for dough to rise.
In the Next.js App Router, a time-based ISR page looks like this. The revalidate export sets the window in seconds:
// app/blog/page.tsx
export const revalidate = 3600; // regenerate at most once per hour
export default async function Blog() {
const posts = await getPosts(); // cached output, refreshed in the background
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}You can also set freshness per data source rather than per page, which is useful when one page pulls from several APIs with different update rates:
// Refresh this particular fetch every 10 minutes, independent of the page.
const posts = await fetch("https://cms.example.com/posts", {
next: { revalidate: 600 },
});Note (Next.js 15 and later). Since Next.js 15,fetchis not cached by default; requests are dynamic unless you opt in. To get ISR/static behavior you explicitly addnext: { revalidate: N }orcache: 'force-cache'. This reversed the older default and is a frequent source of "why is my page suddenly slow / uncached?" surprises during upgrades. You can watch thex-nextjs-cacheresponse header to see what is happening:HIT(served from cache),STALE(served stale, regenerating in the background),MISS(rendered fresh), orREVALIDATED(regenerated on demand).
On-demand revalidation
Time-based windows are coarse. Often you do not want "refresh every hour"; you want "refresh the instant an editor publishes a change." That is on-demand revalidation, triggered from a Server Action or a webhook rather than a clock. You can invalidate by path or by tag:
// app/actions.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function publishPost(slug) {
await db.posts.publish(slug);
revalidatePath(`/blog/${slug}`); // refresh this one page
revalidateTag("posts"); // and any page tagged with "posts"
}To use tags, attach them where you fetch, so a single revalidateTag("posts") can invalidate every page that depends on post data at once:
const posts = await fetch("https://cms.example.com/posts", {
next: { tags: ["posts"] },
});What ISR is good at. Large content sites where data changes but not every second: e-commerce catalogs, news sites, marketplaces, anything with thousands of pages that each update occasionally. You serve static-fast pages and refresh them surgically, either on a timer or the moment the source changes, without ever rebuilding the whole site.
Where ISR hurts. It is more conceptually involved than the others, and the failure modes are subtle: serving stale data longer than you meant to, forgetting to tag a fetch so your invalidation silently does nothing, or cache layers disagreeing about what is current. ISR also shines mainly on infrastructure that supports it well; self-hosting it across multiple server instances requires a shared cache so that one instance regenerating does not leave the others stale.
Stale-While-Revalidate: The Pattern Underneath
You may have noticed ISR's background-refresh behavior has a name buried in it: stale-while-revalidate (SWR). This is not a Next.js invention. It is a general caching strategy, originally from the HTTP Cache-Control spec, and it shows up in three places worth knowing.
The core idea in one sentence: when data is stale, serve the stale copy immediately, and refresh it in the background so the next read is fresh. You trade a little staleness for a lot of speed, and you never block the user on a network round trip.
Analogy. SWR is the way you actually answer "what's the weather?" from memory. You blurt out what you remember from this morning's forecast right away (the stale-but-instant answer), and then you glance at your phone to update yourself for next time. You did not make the person wait while you checked; you answered now and refreshed quietly.
1. As an HTTP header. A CDN or browser can be told to serve a cached response past its freshness window while fetching a new one underneath:
Cache-Control: max-age=600, stale-while-revalidate=86400This says: treat the response as fresh for 10 minutes; after that, keep serving the stale copy for up to a day while you revalidate in the background. The user always gets an instant response, and freshness catches up on its own.
2. As ISR. Everything in the ISR section above is stale-while-revalidate applied to whole pages. The stale page is served instantly; regeneration happens in the background. Same pattern, page-sized.
3. As a client data-fetching library. The popular SWR library (and React Query, with the same philosophy) brings the pattern into the browser for client components. When a component asks for data it already has cached, the library returns the cached value immediately, then refetches in the background and seamlessly swaps in the fresh result:
import useSWR from "swr";
const fetcher = url => fetch(url).then(r => r.json());
function Profile() {
// Returns cached data instantly if present, revalidates in the background,
// and re-renders with fresh data when it arrives.
const { data, error, isLoading } = useSWR("/api/user", fetcher);
if (error) return <p>Failed to load.</p>;
if (isLoading) return <Skeleton />; // only on the very first load
return <h1>Hello, {data.name}</h1>;
}These libraries layer on the features that make the pattern pleasant in practice: automatic refetch when the tab regains focus or the network reconnects, request deduplication so ten components asking for the same data trigger one request, and polling intervals. They are ideal for the interactive, logged-in parts of an app where data should feel live but you do not want a spinner on every navigation.
Rule of thumb. Reach for a client SWR library when data is user-specific, changes while the user watches, and lives in interactive client components: notifications, live counts, a chat sidebar, a dashboard that auto-refreshes. Reach for ISR when the data is shared across users and you want CDN-speed pages: catalogs, articles, listings.
Server Components vs Client Components
Everything so far has been about when HTML is produced. React Server Components (RSC), stable in React 19, add a second, orthogonal axis: where your component code runs and ships. This is the part people most often conflate with SSR, so let us be precise.
A Server Component runs only on the server (at build or request time). It renders to a special serialized format that React streams to the browser, and its JavaScript is never sent to the client at all. Because it runs on the server, it can do things client code cannot: read directly from a database, access the filesystem, use secrets, all without an API layer in between. The price is that it cannot be interactive: no useState, no useEffect, no event handlers, no browser APIs, because none of that exists on the server.
A Client Component, marked with the 'use client' directive at the top of the file, is the React you already know. Its code is shipped to the browser, it hydrates, and it can hold state, run effects, and respond to clicks. It can still be server-rendered first (for fast first paint), but it carries its JavaScript to the client to become interactive.
Analogy. Server Components are the kitchen; Client Components are the dining room. The kitchen (server) does the heavy prep with access to the pantry and the recipes (database, secrets), but customers never go in there and the equipment never leaves. The dining room (client) is where the interactive experience happens: customers sit, press the call button, change their order. A good restaurant does as much as possible in the kitchen and keeps the dining room light, which is exactly the RSC philosophy: default to Server Components, opt into Client Components only where you need interactivity.
The key insight is that these two component types compose in one tree. A Server Component can render Client Components, passing data down as props. The interactive islands ship JavaScript; everything around them does not. Here is the pattern in practice:
// app/product/[id]/page.tsx
// No "use client": this is a Server Component. Its code never ships.
import AddToCart from "./AddToCart";
export default async function ProductPage({ params }) {
// Talk to the database DIRECTLY. No API route, no fetch, no client secret leak.
const product = await db.product.findUnique({ where: { id: params.id } });
return (
<main>
<h1>{product.name}</h1> {/* static, server-rendered, zero JS */}
<p>{product.description}</p>
<AddToCart productId={product.id} /> {/* the only interactive island */}
</main>
);
}// app/product/[id]/AddToCart.tsx
"use client"; // this file's JS DOES ship, because it needs interactivity
import { useState } from "react";
export default function AddToCart({ productId }) {
const [count, setCount] = useState(1);
return (
<div>
<button onClick={() => setCount(c => c - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => addToCart(productId, count)}>Add to cart</button>
</div>
);
}In that example, the product name, description, and layout cost zero client JavaScript. Only the small AddToCart widget ships any. On a content-heavy page, this can dramatically shrink the bundle, because the bulk of the UI is static and never needs to become interactive.
How is this different from SSR?
This is the question interviewers love. The distinction:
- SSR takes your Client Components, runs them on the server to produce an HTML string for fast first paint, and then ships their JavaScript to the client to hydrate. The code runs in both places.
- Server Components run on the server and send a serialized UI description. Their code never reaches the browser and there is nothing to hydrate, because they were never meant to be interactive.
One-liner. SSR is about when the HTML is generated (a performance optimization for first paint). Server Components are about where the code lives and whether it ships (an architecture for shrinking the client bundle and accessing server resources). They are complementary: a Server Component can be rendered on the server and its output streamed, while the Client Components inside it get SSR'd and then hydrated. You use both at once.
Gotcha. The'use client'directive marks a boundary, not a single component. Every component imported into a'use client'file is also part of the client bundle. So putting'use client'high in your tree pulls everything beneath it to the client and defeats the purpose. Push the directive as far down (as close to the actual interactivity) as you can, and keep the static shell as Server Components.
The 2026 Synthesis: Partial Prerendering and Cache Components
For years the choice was binary per route: a page was either fully static (fast, prebuilt, CDN-cached) or fully dynamic (rendered fresh per request). The cruel catch was that touching any request-time data anywhere on the page, a single cookies() read in one small badge component, would tip the entire route into dynamic rendering. You could have a gorgeous mostly-static page dragged down to per-request speed because of one personalized corner. Developers worked around it with export const dynamic hacks or by shoving logic to the client and bloating the bundle. None of it felt right.
Partial Prerendering (PPR) is the answer, and it went stable in Next.js 16 (October 2025) as part of a feature called Cache Components. The idea: a single page ships a static shell prerendered at build time, served instantly, while dynamic "holes" stream into the same response at request time. You stop choosing static or dynamic per route and instead mix both per component, in one page, in one HTTP response.
Analogy. PPR is a printed newspaper with a blank box reserved for a personalized sticker. The paper itself (the static shell: header, layout, articles) is printed in bulk overnight and is ready instantly. The one personalized sticker (your name, your local weather) gets applied per reader as the paper goes out the door. The reader gets the whole printed page immediately, and the small custom bit fills in a beat later, rather than waiting for the entire paper to be reprinted just for them.
The dividing line between static and dynamic is a React <Suspense> boundary. Everything outside a boundary is prerendered into the shell; anything inside one is allowed to render at request time and stream in:
// app/dashboard/page.tsx (Next.js 16, cacheComponents enabled)
import { Suspense } from "react";
import { cookies } from "next/headers";
async function Personalized() {
const theme = (await cookies()).get("theme")?.value; // request-time data
return <p>Welcome back. Your theme is {theme}.</p>;
}
export default function Dashboard() {
return (
<>
<h1>Dashboard</h1> {/* static shell: prerendered, instant */}
<Nav /> {/* static shell */}
<Suspense fallback={<GreetingSkeleton />}>
<Personalized /> {/* dynamic hole: streamed per request */}
</Suspense>
</>
);
}Cache Components also inverts the caching default. In this model nothing is cached unless you say so. You opt specific functions or components into caching with the 'use cache' directive, and tune their lifetime with cacheLife and their invalidation with cacheTag:
// A cached data function: memoized across requests, refreshed on a profile.
import { cacheLife, cacheTag } from "next/cache";
async function getProducts() {
"use cache";
cacheLife("hours"); // built-in TTL profile (or define your own)
cacheTag("products"); // so updateTag("products") can invalidate it
return db.product.findMany();
}// Invalidate on demand from a Server Action when inventory changes:
"use server";
import { updateTag } from "next/cache";
export async function restock(id) {
await db.product.restock(id);
updateTag("products"); // wipes every cache entry tagged "products"
}A few things worth knowing about this newer model. It enables PPR as the default behavior (the old experimental.ppr flag is gone), it deprecates the previous unstable_cache workaround in favor of 'use cache', and Next.js uses React's <Activity> component under the hood to preserve component state as you navigate between routes. In the build output, partially-prerendered routes show up with their own indicator so you can confirm a page actually produced a static shell rather than silently going fully dynamic.
Gotcha. PPR does not replace SSG, SSR, and ISR; it coordinates with them. A page with no per-request data at all is still happiest as pure SSG. A fully personalized page with nothing cacheable is still SSR. PPR shines precisely when a page mixes shared, cacheable structure with per-request dynamic bits, which, in practice, is most real pages. Treat it as the new default and let the genuinely all-static or all-dynamic routes be the exceptions.
Migration gotcha. Because the default flipped from "cached unless you opt out" to "dynamic unless you opt in," upgrading an existing app can silently make previously-static pages render per request. The audit (finding every call site that quietly relied on an implicit cache) is the part you cannot skip; discovering it in production is the expensive way.
How Each Strategy Affects Core Web Vitals
Rendering strategy is not an abstract architecture choice. It shows up directly in the numbers Google measures and ranks you on. Since the March 2024 change, the three Core Web Vitals are:
- LCP (Largest Contentful Paint) measures loading: when the largest visible element finishes rendering. Google's "good" threshold is under 2.5 seconds.
- INP (Interaction to Next Paint) measures responsiveness: how quickly the page reacts across all interactions in a visit (it replaced First Input Delay in March 2024). "Good" is under 200 milliseconds.
- CLS (Cumulative Layout Shift) measures visual stability: how much content unexpectedly jumps around. "Good" is under 0.1.
Note. Google evaluates these at the 75th percentile of real users (from the Chrome User Experience Report), not your fast laptop. A page can feel instant to you and still fail in the field. Many teams also track two diagnostic metrics that rendering strategy strongly influences but that are not themselves Core Web Vitals: TTFB (Time to First Byte) and FCP (First Contentful Paint). And some teams set tighter internal alert budgets than Google's pass line (for example, alerting at LCP 2.0s) to leave headroom; treat those as targets, not the official thresholds.
The crucial mental model: the rendering timeline (CSR/SSR/SSG/ISR/PPR) mostly drives LCP and TTFB, while the component split (Server vs Client Components, and therefore bundle size) mostly drives INP. CLS is largely orthogonal to both and comes down to reserving space. Let us go vital by vital.
LCP: dominated by where and when HTML is built
LCP is "how fast does real content appear," so it rewards having content in the initial response and punishes making the user wait through JavaScript.
- CSR is the worst case. The largest element cannot paint until the bundle downloads, executes, and the client-side fetch resolves. Three serial waits stacked on top of each other.
- SSR is good but bounded by TTFB. The content is in the HTML, so it can paint early, but the server has to render per request first, so a slow data fetch or origin pushes LCP out. LCP here is roughly TTFB plus paint time.
- SSG and ISR are excellent. The HTML is prebuilt and served from a CDN, so TTFB is tiny and the LCP element is already in the markup.
- PPR is excellent. The static shell (which usually contains the LCP element, like a hero or headline) streams immediately, even while personalized holes are still resolving.
// CSR LCP trap: the <h1> cannot paint until after this fetch resolves.
function Article() {
const [post, setPost] = useState(null);
useEffect(() => { fetch(`/api/post`).then(r => r.json()).then(setPost); }, []);
if (!post) return <Spinner />; // LCP clock is still ticking here
return <h1>{post.title}</h1>; // paints late
}
// SSG/PPR fix: the title is in the prebuilt/streamed HTML, paints immediately.
export default async function Article() {
const post = await getPost(); // at build or in the static shell
return <h1>{post.title}</h1>; // already in the HTML the browser receives
}Practical LCP wins by strategy. Move content-critical pages off pure CSR onto SSG/ISR/PPR so the LCP element ships in the HTML. On SSR, attack TTFB: cache the data, keep origin work small, and stream. Regardless of strategy, preload the LCP image and serve it as a modern format, because no rendering trick saves you from a 2 MB hero image.
INP: dominated by JavaScript and hydration, not the timeline
This is the metric most sites fail, and the one people most often misattribute. INP is about main-thread responsiveness, which is governed by how much JavaScript you ship and hydrate, not by when your HTML was generated. SSR a giant client app and you still ship and hydrate a giant bundle; the page looks ready fast (good LCP) but feels janky on first interaction (bad INP) during that hydration window.
- Heavy CSR and heavy SSR both risk INP, because either way a large bundle has to parse, execute, and hydrate, blocking the main thread.
- Server Components are the single biggest lever. Code that stays a Server Component ships zero JavaScript and has nothing to hydrate, so the main thread stays free for the interactions that do exist.
- PPR helps indirectly by encouraging most of the page to be static shell (server-rendered, minimal JS) with small interactive islands.
// INP risk: a big "use client" boundary near the top pulls the whole subtree
// into the bundle and hydrates all of it, blocking the main thread.
"use client";
export default function ProductPage({ product }) {
// entire page is now client JS, even the static description and reviews
}
// INP fix: keep the page a Server Component (zero JS), island the interactivity.
export default async function ProductPage({ params }) {
const product = await db.product.find(params.id);
return (
<>
<h1>{product.name}</h1> {/* server-rendered, no JS */}
<Reviews id={product.id} /> {/* server-rendered, no JS */}
<AddToCart id={product.id} /> {/* the only "use client" island */}
</>
);
}Practical INP wins by strategy. Default to Server Components and push 'use client' as low as possible so you ship the minimum JavaScript. Break long tasks, defer non-critical third-party scripts (chat widgets and analytics are common INP killers), and avoid hydrating large static regions you never need to interact with. The rendering timeline barely moves INP; the bundle does.CLS: about reserving space, slightly helped by server rendering
CLS is mostly independent of the strategy and mostly about whether you reserve space for things before they load. Server-rendered approaches have a small edge because content is present in the initial HTML rather than popping in after a client fetch, but you can still wreck CLS on any of them.
- CSR is the most CLS-prone because content arrives after the fetch and shoves the layout around unless every placeholder is exactly sized.
- SSR, SSG, ISR, and PPR are more stable since the content is in the HTML, provided images, ads, and embeds have explicit dimensions.
- PPR has one specific trap: a streamed dynamic hole whose
<Suspense>fallback is a different size than the real content will shift the layout when it swaps in. Size your skeletons to match.
// CLS trap: fallback height differs from the real content, so it jumps on swap.
<Suspense fallback={<div>Loading...</div>}>
<Recommendations /> {/* renders a tall grid; the layout lurches */}
</Suspense>
// CLS fix: reserve the same space the content will occupy.
<Suspense fallback={<div style={{ minHeight: 320 }}><GridSkeleton /></div>}>
<Recommendations />
</Suspense>Practical CLS wins by strategy. Set explicitwidth/height(or aspect ratios) on every image, video, iframe, and ad slot regardless of strategy. Usefont-display: swapand preload fonts to avoid text reflow. On CSR and on PPR's dynamic holes, make skeletons the same dimensions as the content they stand in for. Never insert content above existing content after load (cookie banners and late pop-ins are classic offenders).
The mapping at a glance
| Strategy | LCP | INP | CLS | TTFB |
|---|---|---|---|---|
| CSR | poor: content waits on JS + fetch | at risk: full bundle hydration | at risk: content pops in | low for the empty shell, but misleading |
| SSR | good, but tied to server speed | at risk: whole-tree hydration | good if dimensions reserved | higher: renders per request |
| SSG | excellent: prebuilt on CDN | depends on shipped JS | good if dimensions reserved | lowest |
| ISR | excellent: cached, served instantly | depends on shipped JS | good if dimensions reserved | lowest (stale-then-fresh) |
| PPR | excellent: shell streams instantly | helped: mostly static shell | good; size Suspense fallbacks | low: shell sent first |
The one-paragraph takeaway. If LCP is your problem, fix it with the timeline: get content into the HTML via SSG, ISR, or PPR, and cut TTFB on SSR. If INP is your problem, fix it with the component split: ship less JavaScript by defaulting to Server Components and islanding interactivity. If CLS is your problem, it is almost never the strategy; reserve space for everything that loads in. Most teams fail INP first, and the cure is architectural (less client JS), which is exactly why Server Components and PPR have become the center of gravity in 2026.
Putting It All Together
In a modern framework you rarely pick one strategy for the whole app. You pick per route, and even mix within a single page. A realistic e-commerce site might look like this:
- Marketing and docs pages: SSG. Same for everyone, change rarely, want them free and instant on a CDN.
- Product catalog and product pages: ISR. Thousands of pages, prices and stock change occasionally, want static speed with background freshness and on-demand revalidation when inventory updates.
- Search results and personalized recommendations: SSR. Different per request, must be fresh, needs to be crawlable and fast on first paint.
- The account dashboard behind login: CSR or client components with a SWR library. Highly interactive, SEO irrelevant, data should feel live.
- Across all of them: Server Components for the static structure (shrinking the bundle), with small Client Component islands for the interactive bits (cart, search box, filters).
Rule of thumb for choosing. Ask three questions in order. (1) Is the content the same for every user? If yes, lean static (SSG or ISR). If no, lean dynamic (SSR or CSR). (2) How fresh must it be? Rarely-changing leans SSG; occasionally-changing leans ISR; always-fresh leans SSR. (3) Does first paint and SEO matter? If yes, render on the server (SSR/SSG/ISR); if it is a private, interactive app, CSR is fine. Then, independently, default your components to Server Components and sprinkle Client Components only where you need interaction.
Quick Reference
| Strategy | Renders at | Per-user? | Freshness | First paint | Best for |
|---|---|---|---|---|---|
| CSR | client (browser) | yes | live (client fetch) | slow (blank then fills) | private interactive apps, dashboards |
| SSR | request time (server) | yes | fresh every request | fast | personalized, always-fresh, SEO pages |
| SSG | build time | no | frozen until rebuild | fastest | docs, marketing, blogs |
| ISR | build + background | no | stale-then-fresh window | fastest | large catalogs, news, marketplaces |
| PPR | build (shell) + request (holes) | shell no, holes yes | static shell + fresh dynamic parts | fastest | pages mixing shared structure with per-user bits |
| Component type | Runs where | Ships JS? | Can be interactive? | Can hit the DB directly? |
|---|---|---|---|---|
| Server Component | server only | no | no | yes |
| Client Component | server (first) + client | yes | yes | no |
Gotchas Worth Remembering
CSR's blank first paint and missing previews. The initial HTML is empty, so first load is slow and social/link-preview crawlers often see nothing. If SEO or shareability matters, do not use pure CSR for that content.
SSR hydration mismatches. Server and client must render identical output.Date.now(),Math.random(), andwindowchecks during render break this. Push browser-only logic into an effect that runs after mount.
SSG staleness and slow builds. Content is frozen at build, and huge sites take forever to build. When either bites, that is your signal to move to ISR.
Next.js 15 changed the fetch default.fetchis no longer cached by default; routes are dynamic unless you opt in withrevalidateorcache: 'force-cache'. Upgrades silently turn previously-static pages dynamic. Check thex-nextjs-cacheheader to confirm what is actually happening.
ISR tags that do nothing. On-demand revalidation by tag only works if you attached that tag to the fetch in the first place. ArevalidateTag("posts")with no fetch tagged"posts"fails silently and you keep serving stale data.
The 'use client' boundary leaks downward. Everything imported into a client file joins the client bundle. Put the directive as low in the tree as possible, right at the interactivity, not at the top of the page.Stale-while-revalidate means the first reader after expiry gets stale data. That is by design, not a bug. One person eats the staleness so everyone after them gets a fast, fresh response. If even that one stale read is unacceptable, you need synchronous revalidation or true per-request SSR instead.
The throughline across all of it: there is no single best strategy, only the right trade for a given route. Anchor every decision to the three moments in time (build, request, client) and the two axes (when HTML is produced, where component code runs), and the whole landscape stops feeling like competing buzzwords and starts feeling like a small set of deliberate, explainable choices.
Related
NodeJS Fundamentals
Master Node.js for interviews: the event loop, async patterns, streams, concurrency, and the beginner traps that quietly sink candidates. With worked examples.
Node.js Memory, Garbage Collection & Production Failures
How Node.js memory really works: the V8 heap, garbage collection, memory leaks, OOM crashes, and diagnosing it all in production on EC2 and ECS.
React Fundamentals
A practical, example-driven React guide from fundamentals to React 19. Master hooks, the Virtual DOM, performance, and the gotchas interviewers actually test.