Rendering Strategy Selection
The Rendering Strategy Problem
You're designing a new app. Before you write a single component, you face a decision that will ripple through every page, every route, every user interaction: where and when does HTML get generated?
Pick wrong and you're stuck with a dashboard that tanks SEO, a blog that ships 200KB of JavaScript for static text, or a real-time feed that hammers your servers on every request.
The landscape has exploded. It used to be "server or client." Now there are nine distinct strategies, and the best apps mix three or four of them within a single deployment. This guide gives you the mental model and decision framework to pick the right strategy for every route, every component, every piece of content.
Think of rendering strategies as delivery methods for a restaurant. CSR is a meal kit — you ship raw ingredients (JS) and the customer cooks at home. SSR is made-to-order — the kitchen prepares each dish when requested, fresh but slow during rush hour. SSG is pre-packaged meals — cooked in advance, instant delivery, but you can't customize. ISR is pre-packaged with a refresh window — yesterday's meal gets swapped out on a schedule. Streaming SSR is courses arriving one by one — the appetizer hits the table while the main course is still cooking. RSC lets you serve some courses pre-plated (zero prep on the customer's end) while others are interactive DIY. PPR is the newest innovation — the plate arrives instantly with the garnish already placed, and the hot food fills in moments later.
The Complete Rendering Landscape
Before choosing a strategy, you need to understand what each one actually does at the network and browser level.
The Decision Framework
Choosing a rendering strategy is not about picking your favorite. It is a function of four variables: content type, interactivity needs, data freshness requirements, and SEO importance.
The Four Variables
Content type — Is the content the same for every user, or personalized? Static content (docs, blog) and dynamic content (dashboard, feed) have fundamentally different rendering needs.
Interactivity — Does the page need JavaScript for core functionality? A marketing page with a hero animation is different from a trading dashboard with real-time charts.
Data freshness — How stale can the content be? A blog post from 2 hours ago is fine. A stock price from 2 hours ago is useless.
SEO — Does this page need to rank in search? Internal dashboards don't. Product pages absolutely do.
Client-Side Rendering (CSR)
CSR was the default for the entire SPA era. The server sends a nearly empty HTML document with a JavaScript bundle. The browser downloads, parses, and executes the JS to build the DOM from scratch.
<!-- What the server sends -->
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/app.bundle.js"></script>
</body>
</html>
Until app.bundle.js downloads, parses, and executes, the user sees a blank white screen. This is CSR's fundamental tradeoff: you sacrifice first paint for maximum interactivity after load.
When CSR is the right call
- Authenticated dashboards — content is user-specific, no SEO needed, rich interactivity
- Internal tools — speed of development matters more than first-paint performance
- Desktop-class apps — Figma, Google Docs, trading platforms where the user expects a loading state
- Offline-capable PWAs — the entire app lives in the browser and works without a server
When CSR is wrong
- Any page that needs SEO (search crawlers don't reliably execute JavaScript)
- Content-heavy pages where users expect instant text (blogs, docs, marketing)
- Mobile users on slow connections (JS bundles are the most expensive bytes)
Google says Googlebot renders JavaScript, and it does — eventually. But crawl budget is limited, rendering is deferred to a "second wave" that can take days or weeks, and any JavaScript errors during rendering silently fail. If SEO matters, do not rely on CSR alone. Server-render the critical content.
Server-Side Rendering (SSR)
SSR generates the full HTML on the server for every request. The user gets a complete, visible page immediately. Then JavaScript downloads and "hydrates" the page — attaching event listeners and making it interactive.
User request → Server executes React → Full HTML response → Browser paints → JS downloads → Hydration → Interactive
The critical insight: the page is visible before it's interactive. The gap between these two events is the hydration cost, and it can be significant.
When SSR is the right call
- Personalized content with SEO needs — product pages with user-specific pricing, localized content
- Dynamic content that changes on every request — search results, filtered listings
- Pages with data that can't be stale — checkout flows, account pages
When SSR is wrong
- Content that rarely changes — you're paying server cost on every request for identical output. Use SSG or ISR instead.
- High-traffic pages — SSR under load means your server is re-rendering the same page for thousands of concurrent users. This is where CDN caching (or ISR) saves you.
Static Site Generation (SSG)
SSG generates every page at build time. The output is plain HTML files that can be served from any CDN with zero server-side computation.
Build time: React renders every page → HTML files written to disk
Request time: CDN serves pre-built HTML → Zero server processing
SSG gives you the best possible TTFB because the response is a static file served from an edge CDN close to the user. No database queries, no server rendering, no computation at all.
When SSG is the right call
- Documentation sites — content changes with deploys, not between them
- Marketing pages — optimized for SEO and performance, rarely updated
- Blog posts — authored, published, done (or updated in a deploy cycle)
- Any page where content is known at build time
The build time problem
SSG's Achilles' heel is build time at scale. If you have 100,000 product pages, you're rendering 100,000 pages on every deploy. Next.js mitigates this with generateStaticParams and on-demand generation, but the fundamental constraint remains: more pages means slower builds.
100 pages → 10 second build ✓
10,000 pages → 15 minute build ⚠️
100,000 pages → hours (impractical without ISR) ✗
Incremental Static Regeneration (ISR)
ISR is SSG with an expiration date. Pages are statically generated at build time, served from the CDN, and regenerated in the background when they go stale.
Time-based revalidation
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.store.com/products/${params.id}`, {
next: { revalidate: 60 }
})
return <ProductDisplay product={await product.json()} />
}
With revalidate: 60, the page is served from cache for 60 seconds. The first request after 60 seconds triggers a background regeneration — the stale page is served immediately, and the fresh version replaces it for subsequent requests.
On-demand revalidation
Time-based revalidation has a problem: you either set a short interval (defeating the purpose of caching) or a long interval (serving stale content). On-demand revalidation solves this by letting you revalidate specific pages when the underlying data actually changes.
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const { productId } = await request.json()
revalidateTag(`product-${productId}`)
return Response.json({ revalidated: true })
}
Your CMS or admin panel calls this API route when a product updates. Only the affected pages regenerate. Everything else stays cached.
ISR follows the same stale-while-revalidate pattern that HTTP caching uses. The user always gets a fast response (even if slightly stale), while the fresh version is prepared in the background. This pattern prioritizes perceived performance over absolute freshness — the right tradeoff for most content.
Streaming SSR with Suspense
Traditional SSR has a waterfall problem: the server must fetch all data, render the entire page, and send the complete HTML before the browser sees anything. If one slow API call takes 3 seconds, the entire page is delayed 3 seconds.
Streaming SSR breaks this waterfall. The server sends HTML progressively as each section completes. Fast parts render immediately. Slow parts show a loading skeleton that fills in when ready.
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<UserProfile />
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</main>
)
}
The server sends <h1> and <UserProfile> immediately. <Recommendations> and <RecentActivity> stream in as their data resolves. The browser progressively renders each chunk — the user sees content within milliseconds instead of waiting for the slowest query.
How streaming actually works
Under the hood, the server uses HTTP chunked transfer encoding. Each <Suspense> boundary defines a streaming chunk. The initial HTML includes the fallback content, and when the server component resolves, it sends a small script that swaps the fallback for the real content.
Chunk 1: <h1>Dashboard</h1> + <UserProfile> + <skeleton placeholders>
Chunk 2: <script>swap('recommendations', '<actual content>')</script>
Chunk 3: <script>swap('activity', '<actual content>')</script>
React Server Components (RSC)
React Server Components are a fundamentally different rendering model. Instead of rendering everything on the server and hydrating everything on the client, RSC splits your component tree into two categories:
- Server Components (the default) — render on the server, ship zero JavaScript, cannot use hooks or browser APIs
- Client Components (marked with
'use client') — render on the server for initial HTML, then hydrate on the client for interactivity
The key breakthrough: static parts of your page (headers, text, images, data displays) ship zero JavaScript to the browser. Only interactive parts (forms, buttons, modals) include their JavaScript.
// Server Component (default) — zero JS shipped to browser
async function BlogPost({ slug }: { slug: string }) {
const post = await db.posts.findBySlug(slug)
const author = await db.users.find(post.authorId)
return (
<article>
<h1>{post.title}</h1>
<AuthorCard author={author} />
<MDXContent content={post.content} />
<LikeButton postId={post.id} />
</article>
)
}
In this tree, <h1>, <AuthorCard>, and <MDXContent> can be Server Components — they display data but have no interactivity. Only <LikeButton> needs to be a Client Component (it handles click events). The result: the majority of the page ships zero JS.
The RSC wire format
Server Components don't send HTML. They send a serialized representation of the React element tree (the RSC payload). This payload describes the component structure without including the JavaScript source code of Server Components. The client-side React runtime reconstructs the tree, merging Server Component output with Client Component code.
This means Server Components can be re-fetched without a full page reload. When you navigate between pages in a Next.js app, only the RSC payload for the new page is fetched — Client Components that haven't changed preserve their state.
Server Components vs. SSR — they are not the same thing
SSR renders your entire component tree to HTML on the server, then hydrates the entire tree on the client. Every component ships JavaScript, even static ones. Server Components selectively exclude JavaScript for components that don't need it. SSR is about when rendering happens (server vs. client). RSC is about whether client-side JavaScript is needed at all for a given component. You can combine both: use SSR to generate the initial HTML (including Server Component output), and only hydrate the Client Components.
Partial Prerendering (PPR)
PPR is an experimental feature in Next.js 15, and it might be the most significant rendering advancement since SSR itself. It combines the instant delivery of SSG with the dynamic capability of SSR at the component level.
The idea is simple: pre-render the static shell of a page at build time, and fill in dynamic holes at request time via streaming.
import { Suspense } from 'react'
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<main>
{/* Static shell — pre-rendered at build time */}
<Navbar />
<ProductLayout>
<Suspense fallback={<PriceSkeleton />}>
{/* Dynamic hole — filled at request time */}
<PersonalizedPrice productId={params.id} />
</Suspense>
<ProductDescription productId={params.id} />
<ProductImages productId={params.id} />
<Suspense fallback={<ReviewsSkeleton />}>
{/* Dynamic hole — filled at request time */}
<Reviews productId={params.id} />
</Suspense>
</ProductLayout>
<Footer />
</main>
)
}
The <Navbar>, <ProductLayout>, <ProductDescription>, <ProductImages>, and <Footer> are pre-rendered at build time and served from the CDN edge. The <PersonalizedPrice> and <Reviews> are dynamic holes — their <Suspense> fallbacks are baked into the static shell, and the real content streams in at request time.
Why PPR matters
Before PPR, you had to choose: is this route static or dynamic? If any part of the page was dynamic, the entire route became dynamic. A product page with personalized pricing had to be fully SSR'd, even though 95% of the page was identical for every user.
PPR eliminates this all-or-nothing choice. The static parts get CDN-level performance. The dynamic parts get request-time freshness. On the same page. On the same route.
Per-Route and Per-Component Strategy Mixing
The most sophisticated apps don't pick one strategy — they mix strategies at the route and component level. This is the real-world approach at companies like Vercel, Shopify, and Netflix.
/ → SSG (marketing page, never changes)
/blog/[slug] → ISR (content updates occasionally, revalidate on publish)
/docs/[...path] → SSG (generated from MDX at build time)
/products/[id] → PPR (static shell + dynamic price/reviews)
/dashboard → CSR (authenticated, real-time, no SEO)
/search → SSR (dynamic, SEO-critical, personalized results)
/checkout → SSR (dynamic, user-specific, secure)
Within a single route, you can mix at the component level using Server Components and Client Components:
// This page mixes three strategies in one route
export default async function ProductPage({ params }: { params: { id: string } }) {
// Server Component — static, zero JS
const product = await getProduct(params.id)
return (
<div>
{/* Server Component — zero JS */}
<ProductHeader name={product.name} description={product.description} />
{/* Client Component — ships JS for interactivity */}
<ImageGallery images={product.images} />
<Suspense fallback={<PriceSkeleton />}>
{/* Async Server Component — streams when data is ready */}
<DynamicPricing productId={params.id} />
</Suspense>
{/* Client Component — ships JS for cart functionality */}
<AddToCart productId={params.id} />
</div>
)
}
The Complete Comparison
| Strategy | TTFB | FCP | TTI | LCP | SEO | Dynamic Data | JS Shipped |
|---|---|---|---|---|---|---|---|
| CSR | Fast (empty HTML) | Slow (waits for JS) | Slow (JS parse + render) | Slow (content from JS) | Poor | Yes (client fetch) | Heavy (full app) |
| SSR | Slow (server render) | Fast (full HTML) | Slow (hydration) | Fast (HTML ready) | Excellent | Yes (per request) | Heavy (hydration) |
| SSG | Instant (CDN edge) | Instant (static HTML) | Fast (minimal JS) | Instant (HTML ready) | Excellent | No (build-time only) | Minimal |
| ISR | Instant (CDN cached) | Instant (static HTML) | Fast (minimal JS) | Instant (HTML ready) | Excellent | Semi (revalidation) | Minimal |
| Streaming SSR | Fast (first chunk) | Very fast (progressive) | Slow (hydration) | Fast (progressive) | Excellent | Yes (per request) | Heavy (hydration) |
| RSC | Varies | Fast (server rendered) | Fast (selective hydration) | Fast (server rendered) | Excellent | Yes (server fetch) | Partial (client only) |
| PPR | Instant (static shell) | Very fast (static + stream) | Fast (selective hydration) | Very fast (shell ready) | Excellent | Yes (dynamic holes) | Partial (client only) |
| Islands | Instant (static HTML) | Instant (static HTML) | Fast (island hydration) | Instant (HTML ready) | Excellent | Per-island | Minimal (islands only) |
| Resumability | Fast (server render) | Fast (full HTML) | Instant (no hydration) | Fast (HTML ready) | Excellent | Yes (server state) | Lazy (on interaction) |
Islands Architecture and Resumability
These two strategies take a fundamentally different philosophical approach from the React ecosystem.
Islands Architecture (Astro)
Islands architecture starts from a radical premise: most of the page is static and should ship zero JavaScript. Interactive components are isolated "islands" that hydrate independently.
In Astro, a page is static HTML by default. You opt into interactivity per component:
---
import Header from '../components/Header.astro'
import SearchBar from '../components/SearchBar.tsx'
import ArticleBody from '../components/ArticleBody.astro'
import CommentsSection from '../components/CommentsSection.tsx'
---
<Header />
<!-- This island hydrates independently — only its JS is loaded -->
<SearchBar client:visible />
<ArticleBody />
<!-- This island hydrates when scrolled into view -->
<CommentsSection client:visible />
Only <SearchBar> and <CommentsSection> ship JavaScript. Everything else is pure HTML. The hydration directives (client:visible, client:idle, client:load) give you fine-grained control over when each island becomes interactive.
Resumability (Qwik)
Qwik eliminates hydration entirely. Instead of replaying all component logic in the browser to attach event listeners, Qwik serializes the application state and event bindings into the HTML. The browser "resumes" where the server left off.
The result: TTI is essentially zero. There's no hydration step. JavaScript loads lazily on interaction — click a button, and only that button's handler code is downloaded and executed.
This is a fundamentally different model from React's. In React, hydration replays your entire component tree. In Qwik, nothing replays — the app is already alive in the HTML.
Islands (Astro) and Resumability (Qwik) offer impressive performance characteristics, but they operate outside the React ecosystem. If your team is invested in React and Next.js, RSC and PPR give you most of the same benefits — selective JavaScript shipping, per-component rendering strategy — within the React model. Choose the approach that fits your team's expertise and ecosystem constraints.
The Decision Flowchart
When in doubt, walk through this:
-
Does the page need SEO?
- No → CSR is viable (dashboards, internal tools, authenticated apps)
- Yes → continue
-
Does the content change between requests?
- No → SSG (blogs, docs, marketing)
- Rarely (hours/days) → ISR with on-demand revalidation
- Yes → continue
-
Is the dynamic content personalized?
- No, same for all users → SSR with CDN caching (or ISR with short revalidation)
- Yes, per-user → continue
-
Is most of the page static with small dynamic parts?
- Yes → PPR (static shell + dynamic holes)
- No, most of the page is dynamic → SSR with Streaming (Suspense boundaries for progressive rendering)
-
For each component on the page: does it need interactivity?
- No → Server Component (zero JS)
- Yes → Client Component (ships JS, hydrates)
| What developers do | What they should do |
|---|---|
| Use SSR for a blog because you want good SEO SSR re-renders identical content on every request, wasting server resources and adding latency. SSG serves from CDN with instant TTFB. | Use SSG or ISR — blog content is known at build time and rarely changes |
| Use CSR for a marketing landing page to keep it simple CSR means a blank screen until JavaScript loads. Search crawlers may not execute JS reliably. Marketing pages are the most important pages to optimize. | Use SSG — marketing pages need fast LCP and SEO |
| Pick one rendering strategy for the entire app A dashboard route has completely different requirements than a product page. Modern frameworks like Next.js let you mix SSG, SSR, ISR, and CSR in the same app. | Mix strategies per-route and per-component based on each page's needs |
| Assume SSR is always better than CSR for performance SSR sends full HTML (fast paint) but then ships all the JS anyway for hydration. The page looks ready but is not interactive — the hydration gap. For highly interactive pages, CSR with a good loading state can feel faster. | SSR improves FCP but can worsen TTI due to hydration cost |
| Use SSR with no caching for high-traffic pages SSR without caching means your server re-renders the same output for every concurrent user. Under load, this causes TTFB degradation and can crash your server. | Use ISR or SSR with CDN cache headers for pages that are the same for all users |
The Strategy Selection Matrix
| Scenario | Best Strategy | Why |
|---|---|---|
| Company blog | SSG or ISR | Content is static, SEO critical, builds are small |
| E-commerce product pages (50K+) | ISR with on-demand revalidation | Too many pages for SSG, but content is mostly static |
| Real-time trading dashboard | CSR | No SEO, real-time data, heavy interactivity |
| Social media feed | Streaming SSR + RSC | Personalized, SEO needed, progressive rendering |
| Documentation site | SSG | Content from MDX at build time, maximum performance |
| SaaS dashboard | CSR or RSC (authenticated) | Behind auth, dynamic, interactive |
| Product page with personalized pricing | PPR | Static product info shell, dynamic price/reviews |
| News site with breaking updates | ISR (short revalidation) or SSR | Freshness matters, SEO critical, high traffic |
- 1No single rendering strategy is best for everything. The right choice depends on content type, interactivity, data freshness, and SEO requirements.
- 2SSG gives you the best TTFB and costs nothing at request time. Use it for any content known at build time.
- 3SSR provides fresh, personalized content but costs server resources on every request. Cache aggressively or use ISR to reduce load.
- 4CSR is valid for authenticated, interactive apps where SEO is irrelevant. Do not use it for any page that needs to rank in search.
- 5Streaming SSR with Suspense eliminates the all-or-nothing waterfall. Slow data sources no longer block fast ones.
- 6RSC and PPR let you make rendering decisions at the component level, not the route level. Static parts ship zero JavaScript.
- 7Mix strategies within a single app. Each route and each component should use the strategy that fits its specific needs.