Skip to content

Rendering Strategy Selection

advanced25 min read

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.

Mental Model

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.

Quiz
You are building an internal admin dashboard for a startup. It requires authentication, shows real-time metrics, and is never indexed by search engines. Which rendering strategy is the strongest fit?

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)
Common Trap

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.
Quiz
A user visits an SSR page. They can see the full page content but clicking buttons does nothing for 2 seconds. What is happening during those 2 seconds?

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) ✗
Quiz
You run an e-commerce site with 50,000 product pages. Products update 5-10 times per day. Which rendering strategy makes the most sense?

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.

Stale-while-revalidate pattern

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>
Quiz
A page has three Suspense boundaries. The data for boundary 2 resolves before boundary 1. What happens?

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.

Quiz
A Next.js 15 page uses PPR. The static shell is 50KB of HTML and the dynamic holes add 5KB via streaming. What is the TTFB characteristic of this page compared to traditional SSR?

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

StrategyTTFBFCPTTILCPSEODynamic DataJS Shipped
CSRFast (empty HTML)Slow (waits for JS)Slow (JS parse + render)Slow (content from JS)PoorYes (client fetch)Heavy (full app)
SSRSlow (server render)Fast (full HTML)Slow (hydration)Fast (HTML ready)ExcellentYes (per request)Heavy (hydration)
SSGInstant (CDN edge)Instant (static HTML)Fast (minimal JS)Instant (HTML ready)ExcellentNo (build-time only)Minimal
ISRInstant (CDN cached)Instant (static HTML)Fast (minimal JS)Instant (HTML ready)ExcellentSemi (revalidation)Minimal
Streaming SSRFast (first chunk)Very fast (progressive)Slow (hydration)Fast (progressive)ExcellentYes (per request)Heavy (hydration)
RSCVariesFast (server rendered)Fast (selective hydration)Fast (server rendered)ExcellentYes (server fetch)Partial (client only)
PPRInstant (static shell)Very fast (static + stream)Fast (selective hydration)Very fast (shell ready)ExcellentYes (dynamic holes)Partial (client only)
IslandsInstant (static HTML)Instant (static HTML)Fast (island hydration)Instant (HTML ready)ExcellentPer-islandMinimal (islands only)
ResumabilityFast (server render)Fast (full HTML)Instant (no hydration)Fast (HTML ready)ExcellentYes (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.

Ecosystem tradeoff

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.

Quiz
What is the fundamental difference between React hydration and Qwik resumability?

The Decision Flowchart

When in doubt, walk through this:

  1. Does the page need SEO?

    • No → CSR is viable (dashboards, internal tools, authenticated apps)
    • Yes → continue
  2. Does the content change between requests?

    • No → SSG (blogs, docs, marketing)
    • Rarely (hours/days) → ISR with on-demand revalidation
    • Yes → continue
  3. Is the dynamic content personalized?

    • No, same for all users → SSR with CDN caching (or ISR with short revalidation)
    • Yes, per-user → continue
  4. 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)
  5. For each component on the page: does it need interactivity?

    • No → Server Component (zero JS)
    • Yes → Client Component (ships JS, hydrates)
What developers doWhat 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

ScenarioBest StrategyWhy
Company blogSSG or ISRContent is static, SEO critical, builds are small
E-commerce product pages (50K+)ISR with on-demand revalidationToo many pages for SSG, but content is mostly static
Real-time trading dashboardCSRNo SEO, real-time data, heavy interactivity
Social media feedStreaming SSR + RSCPersonalized, SEO needed, progressive rendering
Documentation siteSSGContent from MDX at build time, maximum performance
SaaS dashboardCSR or RSC (authenticated)Behind auth, dynamic, interactive
Product page with personalized pricingPPRStatic product info shell, dynamic price/reviews
News site with breaking updatesISR (short revalidation) or SSRFreshness matters, SEO critical, high traffic
Key Rules
  1. 1No single rendering strategy is best for everything. The right choice depends on content type, interactivity, data freshness, and SEO requirements.
  2. 2SSG gives you the best TTFB and costs nothing at request time. Use it for any content known at build time.
  3. 3SSR provides fresh, personalized content but costs server resources on every request. Cache aggressively or use ISR to reduce load.
  4. 4CSR is valid for authenticated, interactive apps where SEO is irrelevant. Do not use it for any page that needs to rank in search.
  5. 5Streaming SSR with Suspense eliminates the all-or-nothing waterfall. Slow data sources no longer block fast ones.
  6. 6RSC and PPR let you make rendering decisions at the component level, not the route level. Static parts ship zero JavaScript.
  7. 7Mix strategies within a single app. Each route and each component should use the strategy that fits its specific needs.