renderpx

Rendering Strategy

When does your page render, and where?

The Problem

The default React mental model is client-side rendering: the server sends an empty HTML shell, JavaScript loads, fetches data, and the page appears. For a while, this felt fine. Then product teams started asking why their pages weren't showing up on Google.

The problem is twofold. First, search engine crawlers see an empty <div id="root"></div>, not your content. Second, the loading waterfall punishes users on slow connections: blank page → JS downloads → fetch starts → fetch resolves → page finally renders. Four sequential steps before anything useful appears.

Simulate it below. The timeline shows why CSR feels slow; each step only starts after the previous one finishes.

Simulation — each step blocks the next
CSR Loading Waterfall
HTML received
<div id="root"></div> — empty shell
JavaScript parsed
Bundle downloaded and executed
Data fetch
GET /api/products/123
What the user sees:
Press "Simulate load" to start
Each step blocks the next — that's the waterfall.
CSR product page — SEO-invisible, slow waterfalltsx
Loading...

The Five Rendering Modes

The answer isn't to replace CSR; it's to understand what each mode trades off, and match it to the page's actual needs. Select a mode below to see what HTML arrives in the browser and what the tradeoffs are.

What does the browser receive?
<div id="root"></div> <!-- empty — JS fills this in -->
TTFB
< 5ms
Freshness
Always fresh — fetched in browser on every load
CDN-cacheable
✓ Yes
Personalizable
✓ Yes
SEO-crawlable
✗ No

The key insight: SSR, SSG, and ISR all send fully-populated HTML to the browser. The difference is when that HTML was generated: on every request (SSR), at build time (SSG), or from a CDN cache that refreshes on a timer (ISR). CSR sends nothing and generates it in the browser.

The Framework: Two Questions

Every rendering decision comes down to two questions about your data:

How often does this data change?
  • Never / rarely SSG — build it once
  • Every few minutes/hours ISR — CDN with revalidation
  • Every request SSR — always fresh
  • On user action CSR — fetch in browser
Is this data the same for all users?
  • Yes — same HTML for everyone SSG or ISR (CDN-cacheable)
  • No — varies per user SSR (server knows the user) or CSR (client knows auth)
  • Mixed — static shell + dynamic parts RSC — per-component control

The practical starting point: ISR

For most public-facing pages where data changes, ISR is the right default. It gives you static file performance (CDN, sub-5ms TTFB) with automatic freshness; no rebuild pipeline needed.

tsx
Loading...

When pages get complex: RSC + Streaming

A product page isn't one piece of data. The product details change rarely; ISR is fine. The live review count changes constantly; it needs a fresh fetch. The "Add to Cart" button is interactive; it needs JavaScript. React Server Components let you make these decisions per component, not per page.

tsx
Loading...

What "streaming" means in practice: the server sends the product HTML immediately (from cache) and streams the <ReviewsFeed> chunk separately as it resolves. The user sees a complete product page with a reviews skeleton, and the reviews fill in ~200ms later, with no full-page loading spinner.

Decision Matrix

A reference for matching rendering strategy to page characteristics. Most production apps use two or three of these strategies across different pages.

StrategyTTFBSEOFreshnessUse When
CSR< 5msNoAlways fresh
Auth-gated dashboards, internal tools, highly interactive SPAs
Avoid: Any page where SEO or initial load performance matters
SSR100–400msYesAlways fresh
User-specific pages that need SEO (profile pages, account views)
Avoid: High-traffic, uncached content — every request hits your server
SSG< 5msYesStale until rebuild
Marketing pages, docs, blog posts — content that rarely changes
Avoid: Data that changes more often than you can afford to rebuild
ISR< 5msYesStale up to TTL
Product catalogs, news, e-commerce — content that changes, but not per-second
Avoid: Data where a 60-second window of staleness is unacceptable
RSC< 5msYesPer-component
Complex pages with mixed rendering needs — different components have different data
Avoid: Teams new to React or not on Next.js App Router

Progressive Complexity

The same feature (a product detail page) built with each rendering strategy. Each example shows exactly what changes, what it gains, and what constraint drives you to the next step.

Example 1: Client-Side Rendering

Naive

Default React, empty HTML shell, browser fetches data

Client-Side Rendering: the default React approach. An empty HTML shell is sent; JavaScript fetches data in the browser after it loads. Zero server work, maximum waterfall.

tsx
Loading...

Why this works

Works when: • Internal tools where SEO doesn't matter • Content behind authentication (crawlers can't see it anyway) • Highly dynamic, user-specific data • Rapid prototyping Zero backend complexity. Works on any static host (GitHub Pages, S3).

When this breaks

Product pages with CSR are invisible to search engines — Googlebot sees an empty HTML shell. The loading waterfall is also the main UX problem: • Browser requests HTML → receives empty shell • Browser parses and runs JS bundle • JS kicks off fetch to /api/products/123 • Fetch resolves → component renders On a slow mobile connection that's 4–6 seconds of nothing. Lighthouse will punish you with a low LCP score.

Production Patterns

The e-commerce site that moved from SSR to ISR and cut server costs 80%

A mid-sized retailer with ~5,000 products was SSR-ing every product page because "we need fresh prices." Prices changed once or twice per day via a batch sync job.

Problem: At peak traffic, the DB was hit 40,000 times/hour for product data that was identical between requests. Server costs were scaling linearly with traffic.
Fix: Switched to ISR with revalidate: 300 (5 minutes). The CDN now absorbs ~95% of traffic. DB queries dropped to a trickle, only when cache entries expire.
The one edge case: Flash sales. We added an on-demand revalidation call (revalidatePath) triggered when a sale starts. The CDN invalidates those pages immediately; no waiting for the TTL.
What I'd do earlier: implement on-demand revalidation from day one. The TTL-only approach works 99% of the time, but flash sales and urgent content corrections need a way to bypass it.

The dashboard that taught me about the CSR/RSC boundary

A user dashboard migrated from a Vite SPA (pure CSR) to Next.js App Router. The instinct was to add 'use client' to every component that used state or effects.

What happened: The entire page tree was a Client Component. No RSC benefit; same JS bundle size as before, same CSR waterfall.
The fix: Draw a clear boundary. The page shell, nav, and header are Server Components (static, no JS shipped). The data-fetching layer is Server Components (async/await, directly to DB). Only interactive islands (charts, filters, modals) are Client Components.
Rule of thumb that stuck: Push 'use client' down to the leaves. Default to Server Components everywhere else.

Migrating a CSR app to Next.js without a rewrite

The instinct when adding Next.js is to move everything at once. The right instinct is to start with the pages that hurt most: the ones Google can't index, or the ones with the worst LCP on mobile. Next.js supports both CSR components and SSR/SSG pages in the same app; you're adding a rendering layer, not rewriting components.

Migrate route by route. For each route, answer two questions: does this content need to be indexed? Does it change per user? Those answers determine the rendering strategy. The key call per route: if the content doesn't change per user, default to SSG. If it changes hourly, use ISR. Only reach for SSR when you genuinely need per-request freshness, not as a cautious default.

tsx
Loading...

A Real Rollout

What it actually looks like to split rendering strategy across a codebase, with a marketing team, an engineering team, and a product that can't stop shipping.

Context

Marketing site and logged-in app in the same Next.js codebase. The marketing pages needed SEO and sub-second first contentful paint; the logged-in app was data-heavy and highly interactive. Both were being served from the same deployment, but with the same rendering strategy applied to everything.

The problem

Marketing pages were rendering client-side, part of the same SPA entrypoint. Google wasn't indexing landing page content correctly; Core Web Vitals flagged poor LCP across the board. Switching everything to SSR would hurt the logged-in experience; the app had too many per-user queries to run server-side efficiently on every pageview. The team was treating “rendering strategy” as an app-level decision rather than a route-level one.

The call

Split rendering strategy by route group. Marketing pages (/pricing, /features, /blog) → SSG with ISR, revalidating on CMS publish. Auth-gated app (/app/*) → CSR with React Query, serving from a CDN-cached shell. The line was drawn at the route prefix. RSC was considered but the team wasn't ready for the mental model shift, and the marketing pages didn't need per-component rendering control.

How I ran it

The hardest conversation was with the marketing team. “Static generation” sounds like old-school websites to people used to live CMSes. The reframe that worked: “the page rebuilds on publish, just like your CMS preview, but it's instant for every visitor instead of re-rendering on every request.” Engineers needed reassurance that ISR wouldn't serve stale pricing; we added a CMS webhook that triggered revalidatePath on publish, so pricing pages were always rebuilt immediately after a content change. That removed the staleness objection completely.

The outcome

LCP on marketing pages improved significantly within weeks of deployment. Organic search indexing improved; Google could now read page content on first crawl. The /app section stayed fully interactive with no regression. Server costs dropped because marketing pages no longer consumed SSR capacity on every visitor request; they served from CDN. The marketing team adopted the CMS publish workflow as their new normal within two weeks.

Common Mistakes & Hot Takes

Defaulting to SSR because it's 'safe'

SSR is not free. Every request spins up a server process, runs a DB query, and sends a response. Under load, this is expensive and slow. If data doesn't change per-request, use ISR. If data doesn't change per-day, use SSG. SSR should be your choice when you actually need per-request freshness, not your fallback when you don't know what else to use.

Putting 'use client' at the top of the page

This is the App Router equivalent of opting out of everything that makes it interesting. I've seen large Next.js 13+ apps that are functionally identical to their Vite SPA predecessors because every component was marked 'use client'. The rule: 'use client' belongs at the leaf components that actually need interactivity: buttons, forms, charts. Not at the page root.

Treating ISR's stale window as a problem to eliminate

The 60-second staleness window is a feature, not a bug. A user who loaded a product page at 9:00am and the price changed at 9:00:30am is fine; they didn't know to expect a different price. The mental model shift is: ISR gives you an accuracy guarantee, not a real-time one. Real-time is for live prices on a trading platform, not an e-commerce catalog.

Building complex SSR APIs instead of using RSC

I once built a custom server middleware that pre-fetched data, injected it into the HTML as JSON, then hydrated it on the client. It was essentially a bad version of RSC. React Server Components are the framework's native answer to server data fetching. If you're building elaborate SSR data injection patterns in App Router, you're likely fighting what the framework already gives you.