Rendering Strategies
Rendering Strategies
Five rendering strategies on a spectrum from fully server-computed to fully client-computed — SSG, ISR, SSR, CSR, and PPR (streaming) — each with distinct trade-offs in TTFB, TTI, SEO, and caching, with both Next.js App Router and Angular SSR as first-class ecosystem examples.
Intent
The rendering problem is: at what point in the request lifecycle does the server produce HTML? The answer determines how fast users see content (TTFB), how fast it becomes interactive (TTI), whether search crawlers can index it (SEO), and how much server compute is consumed per request.
The spectrum runs from build-time HTML generation (fastest delivery, static content only) through periodic cache revalidation, full per-request server computation, and finally to client-side JavaScript construction (slowest initial paint, fully dynamic).
Modern frameworks have moved beyond the old per-page rendering model. Next.js 15+ (App Router with cacheComponents: true) and Angular 19+ (RenderMode enum in app.routes.server.ts) now support per-component rendering decisions within a single route. A single HTTP response can carry a build-time static shell, cached components with TTL-based revalidation, and dynamic streaming slots — all in one request. Choosing a rendering strategy now means selecting a default for a route and overriding it at the component level.
TypeScript-only examples are used throughout — client-side rendering has no Java equivalent, consistent with the Micro-Frontends precedent in this vault. All examples draw from both React/Next.js and Angular ecosystems in the same note.
When NOT to Use
Specific strategies to avoid in specific contexts:
- Do not use SSR for pages that never change — use SSG or ISR instead. Rendering the same HTML on every request wastes server compute and inflates TTFB; a CDN-served static file is faster and free of per-request cost.
- Do not use CSR for SEO-critical pages — crawlers receive an empty HTML shell; organic search ranking collapses. Use SSR or SSG for any page that must be indexed.
- Do not use ISR with a TTL of 0 — this is equivalent to SSR but with stale risk. If data must be fresh on every request, use
export const dynamic = 'force-dynamic'explicitly rather than relying on a zero-second revalidation window. - Do not use full SSR for behind-auth dashboards in Angular — SSR runs before client-side auth tokens are available; the server renders an empty or unauthenticated shell, producing flash-of-unauthenticated-content and redirect loops. Use
RenderMode.Clientfor all auth-gated routes. - Do not default to CSR "because it is simpler" — both Next.js and Angular now provide zero-config SSR and SSG. The historical complexity argument is outdated; CSR should be a deliberate choice for auth-gated or highly interactive pages, not the path of least resistance.
When to Use
SSG (Static Site Generation): Content is known at build time and has no per-user variation. Best for marketing sites, documentation, and blogs where content changes infrequently and CDN distribution provides maximum global performance.
ISR / use cache (Incremental Static Regeneration): Content changes periodically and a staleness window is acceptable. Best for product catalogs, CMS-driven pages, and aggregated feeds where rebuilding on every request is unnecessary but content cannot be frozen forever.
SSR (Server-Side Rendering): Each request requires per-user personalisation, real-time data, or server-side auth context. Best for authenticated dashboards with server-rendered user data, real-time feeds, and pages where content differs per visitor.
CSR (Client-Side Rendering): The application is heavily interactive, SEO is irrelevant, and users are always authenticated before seeing content. Best for admin dashboards, internal tooling, and auth-gated single-page applications.
PPR (Partial Prerendering / Streaming): A single route mixes static, cached, and dynamic content. Best for e-commerce product pages (static product info + personalised recommendations), news sites (static article body + dynamic comments), and any page where the static shell can be served instantly while dynamic slots stream in.
Strategy Comparison Table
| Strategy | When HTML is produced | Server involved per request | TTFB | TTI | SEO | Caching |
|---|---|---|---|---|---|---|
| SSG | Build time | No | Fastest (CDN-served) | Fast (minimal JS) | Excellent | Indefinite (until rebuild) |
ISR / use cache | Build + periodic revalidation | No (cache hit) / Yes (miss) | Fast (CDN) | Fast | Excellent | TTL-based (cacheLife) |
| SSR | Every request | Yes | Slow (server compute) | Moderate (hydration overhead) | Excellent | None (or short-lived) |
| CSR | Client browser | No | Fast (empty shell) | Slow (JS parse + fetch) | Poor (crawler sees blank) | Varies |
| PPR (streaming) | Build-time shell + streaming fill | Partial | Fast shell + streaming | Good (progressive) | Excellent (shell indexed) | Per-component |
Rendering Flow Diagrams
SSG / ISR flow — build-time pre-rendering with optional TTL revalidation:
sequenceDiagram
participant Build as Build Server
participant CDN as CDN / Edge
participant Browser as Browser
Build->>CDN: Pre-rendered HTML + JS bundle (build time)
Browser->>CDN: GET /page
CDN->>Browser: Cached HTML (TTFB: fastest)
Browser->>Browser: Parse HTML, hydrate JS
Note over CDN,Browser: ISR: CDN serves stale, triggers background revalidation on TTL expiry
SSR / PPR (streaming) flow — per-request server rendering with optional streaming:
sequenceDiagram
participant Browser as Browser
participant Server as Server (Node / Angular SSR)
participant DB as Data Source
Browser->>Server: GET /page
Server->>DB: Fetch data (parallel with Promise.all)
DB->>Server: Data response
Server->>Browser: Full HTML (SSR) or static shell + streaming chunks (PPR)
Browser->>Browser: Parse HTML, hydrate JS
Note over Server,Browser: PPR: static shell sent immediately; dynamic <Suspense> boundaries stream as data resolves
SSG — Static Site Generation
SSG generates HTML at build time. The output is a set of static files deployed to a CDN; no server compute is involved per request. This is the fastest delivery model — TTFB is bounded only by CDN latency. The constraint is that content must be known at build time, making SSG unsuitable for real-time or per-user content.
Next.js — generateStaticParams
generateStaticParams enumerates the dynamic path segments that should be pre-rendered. The page component is a standard async function; the framework calls it for each returned param set at build time.
// Source: nextjs.org/docs/app/api-reference/functions/generate-static-params
// Pre-render a set of dynamic paths at build time
export async function generateStaticParams() {
const posts = await db.posts.findAll()
return posts.map(post => ({ slug: post.slug }))
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await db.posts.findBySlug(params.slug)
return <article>{post.content}</article>
}Angular — RenderMode.Prerender
Angular declares per-route rendering modes in app.routes.server.ts. RenderMode.Prerender with getPrerenderParams() mirrors Next.js's generateStaticParams — the framework calls getPrerenderParams() at build time to enumerate all paths.
// Source: angular.dev/guide/ssr
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender }, // SSG — marketing home
{ path: 'about', renderMode: RenderMode.Prerender }, // SSG — static content
{ path: 'blog/:slug', renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = await db.posts.findAll()
return posts.map(p => ({ slug: p.slug }))
}
},
]SSG pages are CDN-served with no per-request server compute. The tradeoff is that content is as stale as the last build — use ISR when content changes on a cadence shorter than your build cycle.
The key operational advantage of SSG is cost predictability: traffic spikes do not increase server costs because there is no server in the request path. A viral post on a statically-generated blog site costs the same per request whether it receives 100 or 100,000 visitors in an hour.
ISR — Incremental Static Regeneration / Cache Components
ISR evolved into the per-component 'use cache' model in Next.js App Router. Rather than setting a revalidation window for an entire page, caching is declared at the component or data-fetch level. A single route can have components with different cache lifetimes — some cached for days, others for hours, others not at all.
Angular's equivalent is RenderMode.Prerender at the route level combined with server-side cache invalidation. For Angular apps using an API layer, the cache TTL is typically managed at the data layer rather than in the rendering framework.
The mental model shift from ISR to 'use cache' is significant: ISR was a page-level property set once in the route file; 'use cache' is a per-component or per-function annotation. This enables mixing cache lifetimes within a single page — a product's price might use cacheLife('minutes') while the product description uses cacheLife('days').
Next.js — 'use cache' + cacheLife
The 'use cache' directive marks a component or async function as cacheable. cacheLife() sets the revalidation lifetime. When combined with <Suspense>, this is PPR: the cached component is part of the static shell; the <Suspense>-wrapped dynamic component streams at request time.
// Source: nextjs.org/docs/app/building-your-application/rendering/partial-prerendering
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { cacheComponents: true }
export default nextConfig
// app/product/[id]/page.tsx
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
// Cached component — part of the static shell
async function ProductInfo({ id }: { id: string }) {
'use cache'
cacheLife('days')
const product = await db.products.findById(id)
return <h1>{product.name}</h1>
}
// Dynamic component — streams at request time
async function UserRecommendations({ userId }: { userId: string }) {
const recs = await getPersonalisedRecs(userId)
return <ul>{recs.map(r => <li key={r.id}>{r.name}</li>)}</ul>
}
export default function Page({ params }: { params: { id: string } }) {
return (
<>
<ProductInfo id={params.id} />
<Suspense fallback={<p>Loading recommendations...</p>}>
<UserRecommendations userId="current" />
</Suspense>
</>
)
}PPR/Cache Components requires cacheComponents: true in next.config.ts. The 'use cache' + cacheLife API is the current caching model in Next.js App Router (v15+), replacing the legacy export const revalidate pattern from Pages Router. In App Router projects, export const revalidate at the route level is superseded by component-level cacheLife() when cacheComponents: true is set — the route-level directive has no effect.
SSR — Server-Side Rendering
SSR computes the full HTML response per request. The server has access to request context — cookies, headers, session tokens — making it the right choice for personalised content, real-time data, and auth-gated server-rendered pages. The tradeoff is TTFB: the server must complete all data fetching and rendering before any bytes are sent to the browser (unless streaming is used).
Next.js — force-dynamic
export const dynamic = 'force-dynamic' opts an entire route into per-request SSR with no caching. The page component can then read request context via Next.js server utilities.
// Source: nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
// Opt an entire route into per-request SSR
export const dynamic = 'force-dynamic'
export default async function DashboardPage() {
const user = await getCurrentUser() // reads request cookies
return <Dashboard userId={user.id} />
}Angular — RenderMode.Server
In app.routes.server.ts, RenderMode.Server declares that a route is server-rendered on every request. This is Angular's explicit SSR mode.
// Source: angular.dev/guide/ssr
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{ path: 'feed', renderMode: RenderMode.Server }, // SSR — real-time data
]Avoid sequential data fetches in SSR. Parallel fetching with Promise.all() prevents TTFB from being the sum of all API call durations rather than the max. Wrap per-section data in <Suspense> boundaries (Next.js) or @defer blocks (Angular) so the static shell streams immediately while dynamic sections arrive in parallel.
CSR — Client-Side Rendering
CSR sends an empty HTML shell to the browser. JavaScript downloads, parses, and executes to build the page entirely in the client. SEO is poor because crawlers receive no meaningful content. TTI is slow because users wait for the full JS bundle before they can interact with the page.
CSR is the right choice when SEO is irrelevant (auth-gated tools, internal dashboards) and when SSR would add server cost without any user-visible benefit.
Angular — RenderMode.Client
In Angular, CSR is declared explicitly per route using RenderMode.Client. This is the recommended configuration for all routes behind authentication.
// Source: angular.dev/guide/ssr
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{ path: 'dashboard/**', renderMode: RenderMode.Client }, // CSR — auth-gated
]RenderMode.Client routes are not server-rendered at all — Angular skips SSR for these routes entirely, avoiding the unauthenticated-content flash that occurs when SSR runs before client-side auth tokens are available.
React applications without Next.js (plain Vite, Create React App) are CSR by default — the build output is a static index.html with a JS bundle. Next.js App Router does not have a "CSR-only" mode per route; components that run in the browser without 'use cache' are effectively CSR, but the route-level default is still SSR unless overridden.
The canonical Angular CSR configuration adds a wildcard entry for all auth-gated routes:
// Source: angular.dev/guide/ssr
// app.routes.server.ts — full configuration showing all three render modes
import { RenderMode, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender }, // SSG
{ path: 'blog/:slug', renderMode: RenderMode.Prerender,
async getPrerenderParams() { return db.posts.findAll().then(p => p.map(x => ({ slug: x.slug }))) }
},
{ path: 'feed', renderMode: RenderMode.Server }, // SSR
{ path: 'dashboard/**', renderMode: RenderMode.Client }, // CSR — auth-gated
]PPR — Partial Prerendering (Streaming)
PPR is the modern default in Next.js with cacheComponents: true. A single route has a static shell — built at build time and cached — plus dynamic slots that stream via <Suspense> as data resolves at request time. The browser receives the static shell immediately (fast TTFB) and displays dynamic content as it arrives (progressive TTI).
PPR is not a fifth distinct strategy alongside SSR, SSG, ISR, and CSR — it is a hybrid: the static shell uses ISR semantics (build-time cached), and the dynamic slots use SSR semantics (per-request computed). The key innovation is that both happen in one HTTP response via HTTP chunked transfer.
In Angular, the combination of RenderMode.Prerender plus @defer blocks within a template achieves a similar per-component rendering split: the route is pre-rendered at build time, and @defer blocks within the template stream in as data becomes available or trigger conditions are met.
Next.js — PPR in Action
The same 'use cache' + <Suspense> example from the ISR section above is PPR in action — the static ProductInfo component is the cached shell, and the UserRecommendations component streams at request time. No additional configuration is needed beyond cacheComponents: true in next.config.ts.
The mental model: any component decorated with 'use cache' becomes part of the static shell; any component wrapped in <Suspense> without 'use cache' streams dynamically.
Angular — Hybrid Rendering
Angular achieves per-component rendering split through two orthogonal mechanisms:
- Route-level
RenderModeinapp.routes.server.tsdetermines whether a route is pre-rendered (SSG), server-rendered per request (SSR), or client-rendered (CSR). @deferblocks within a template determine which parts of a pre-rendered or server-rendered page are deferred — either for lazy loading or for incremental hydration.
A route with RenderMode.Prerender that uses @defer blocks for personalised sections achieves the Angular equivalent of PPR: the route's shell is built at build time; defer blocks load (and optionally hydrate) when their trigger conditions are met.
See Hydration-Patterns for the @defer (hydrate on ...) trigger syntax and incremental hydration configuration.
Hydration Implications
Rendering strategy choice directly constrains which hydration strategy is appropriate. SSG pages ship minimal JavaScript and benefit most from partial or islands hydration — full hydration of a static page wastes the hydration budget on components that have no interactivity. SSR pages typically use full or progressive hydration because the server renders the full component tree and hydration must restore all interactivity. CSR pages have no hydration — there is no server HTML to hydrate. PPR pages use a combination of progressive and islands hydration: the static shell hydrates first, and dynamic slots hydrate progressively as they stream in.
| Rendering Strategy | Recommended Hydration | Why |
|---|---|---|
| SSG | Partial or islands | Static pages ship minimal JS; full hydration wastes budget |
ISR / use cache | Partial | Cached content is quasi-static; same JS reduction rationale |
| SSR | Full or progressive | Server renders full tree; hydration restores interactivity |
| CSR | N/A | No server HTML exists; nothing to hydrate |
| PPR | Progressive + islands | Static shell first; dynamic slots hydrate as they stream in |
See Hydration-Patterns for full coverage of hydration strategies including Angular incremental hydration and Astro islands architecture.
Common Pitfalls
Pitfall 1: Treating Next.js App Router as Pages Router
What goes wrong: Developer uses getStaticProps, getServerSideProps, or export const revalidate expecting the Pages Router ISR model. In the App Router with cacheComponents: true, export const revalidate is superseded by the 'use cache' directive with cacheLife() — the route-level directive takes no effect.
Why it happens: Most tutorials and Stack Overflow answers pre-date App Router v15+.
How to avoid: In App Router projects (v13+), control caching at the component or function level with 'use cache'. Use export const dynamic = 'force-dynamic' only to force full SSR on an entire route.
Warning signs: export const revalidate at the top of a route file alongside cacheComponents: true in next.config.ts.
Pitfall 2: SSR with Blocking Data Fetches (Sequential Waterfalls)
What goes wrong: A server-rendered page awaits three sequential API calls before sending any HTML. TTFB balloons to 1500ms+ because the server is blocked on each sequential await.
Why it happens: async/await chains in server components compose sequentially unless parallelised. Each await blocks the entire page response when not wrapped in <Suspense>.
How to avoid: Parallel data fetching with Promise.all() for independent calls. Wrap per-section data fetches in <Suspense> boundaries so the static shell streams immediately while dynamic sections arrive later.
Warning signs: Server response time proportional to the sum of all API call durations instead of the maximum.
Pitfall 3: Using CSR for Behind-Auth Pages in Angular with SSR Enabled
What goes wrong: An authenticated dashboard uses RenderMode.Server for all routes by default. On first load, the server renders the dashboard before client-side auth tokens are available — producing a flash of unauthenticated content and auth redirect loops.
Why it happens: SSR runs in a server context with no access to browser-stored auth tokens.
How to avoid: Set renderMode: RenderMode.Client in app.routes.server.ts for all routes behind authentication. These routes are not indexed by search engines; SSR adds cost with no SEO benefit.
Warning signs: Authentication redirect loops in SSR server logs. Flash of empty server-rendered content before client-side auth resolves.
Pitfall 4: Using SSR for Pages That Never Change
What goes wrong: A static marketing page is server-rendered on every request. The page has no per-user content and changes only when the copy is updated. Each request consumes server compute and incurs the full SSR TTFB penalty — typically 200–800ms — when a CDN-served static file would respond in 10–50ms.
Why it happens: SSR is the default in many frameworks, so teams accept it without auditing which routes actually need per-request rendering.
How to avoid: Audit routes for actual per-request data requirements. Pages with no per-user content and infrequent changes should use RenderMode.Prerender (Angular) or generateStaticParams / 'use cache' (Next.js).
Warning signs: SSR logs showing identical HTML output across requests for the same route. Server compute costs proportional to traffic volume on content-only pages.
Related Concepts
| Concept | Relationship | Link |
|---|---|---|
| Hydration Patterns | Rendering strategy constrains hydration approach | Hydration-Patterns |
| State Management Patterns | SSR compatibility is a paradigm selection dimension | State-Management-Patterns |
| Micro-Frontends | Each micro-frontend may use a different rendering strategy | Micro-Frontends |
| CQRS Pattern | ISR read models are eventually consistent — same staleness semantics | CQRS-Pattern |
Sources
- angular.dev/guide/ssr — Angular hybrid rendering,
RenderModeenum,ServerRoute[], hybrid rendering configuration - nextjs.org/docs/app/building-your-application/rendering/partial-prerendering — PPR,
use cache,cacheLife,Suspensestreaming,cacheComponentsflag - vercel.com/blog/how-to-choose-the-best-rendering-strategy-for-your-app — rendering strategy selection framework, TTFB/TTI guidance
- docs.astro.build/en/concepts/islands/ — islands architecture reference (cross-linked from Hydration-Patterns)