Rendering Strategies (SSR/SSG/ISR)
MedRendering strategy determines when and where HTML is generated for your application. Client-Side Rendering (CSR), Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) each serve different performance and SEO trade-offs. Choosing the right strategy per page is a critical frontend system design decision.
Interactive Visualization
Key Points
- CSR renders entirely in the browser, best for highly interactive apps behind auth
- SSR generates HTML per request, ideal for personalized or frequently-changing content with SEO needs
- SSG pre-builds pages at deploy time, giving the fastest TTFB for static content
- ISR combines SSG speed with periodic revalidation for content that changes periodically
- Streaming SSR sends HTML progressively, improving Time to First Byte for slow data sources
- React Server Components reduce client bundle size by keeping server-only code off the client
- Hybrid rendering applies different strategies to different routes within the same app
Code Examples
Choosing Rendering Strategy by Page Type
// Strategy decision matrix // // ┌─────────────────┬──────────┬─────────┬──────────────┐ // │ Page Type │ Strategy │ TTFB │ SEO │ // ├─────────────────┼──────────┼─────────┼──────────────┤ // │ Marketing/Blog │ SSG │ ~50ms │ Excellent │ // │ Product listing │ ISR │ ~50ms │ Excellent │ // │ Search results │ SSR │ ~200ms │ Good │ // │ User dashboard │ CSR │ ~100ms │ Not needed │ // │ E-commerce PDP │ ISR │ ~50ms │ Excellent │ // │ Social feed │ SSR+CSR │ ~150ms │ Good (shell) │ // └─────────────────┴──────────┴─────────┴──────────────┘ // Next.js: SSG (default for static pages) export default function BlogPost({ post }: { post: Post }) { return <article>{post.content}</article> } export async function generateStaticParams() { const posts = await getPosts() return posts.map(p => ({ slug: p.slug })) } // Next.js: ISR with on-demand revalidation export const revalidate = 3600 // Revalidate every hour // Next.js: SSR (dynamic, no caching) export const dynamic = 'force-dynamic'
No single rendering strategy fits all pages. System design interviews expect you to justify your choice per route based on data freshness needs, SEO requirements, and interactivity level.
Streaming SSR with Suspense
// Streaming SSR: send the shell immediately, stream data later // Server Component (Next.js App Router) import { Suspense } from 'react' export default function ProductPage({ params }: { params: { id: string } }) { return ( <div> {/* Shell renders immediately */} <ProductHeader id={params.id} /> {/* Reviews stream in when ready */} <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews id={params.id} /> </Suspense> {/* Recommendations stream in independently */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations id={params.id} /> </Suspense> </div> ) } // Each async component fetches its own data async function ProductReviews({ id }: { id: string }) { const reviews = await fetchReviews(id) // 400ms return <ReviewList reviews={reviews} /> } async function Recommendations({ id }: { id: string }) { const recs = await fetchRecommendations(id) // 800ms return <RecGrid items={recs} /> } // Timeline: // 0ms → Shell HTML sent (header, skeletons) // 400ms → Reviews chunk streamed in // 800ms → Recommendations chunk streamed in
Streaming SSR with Suspense sends the page shell immediately and streams in data-dependent sections as they resolve. This dramatically improves perceived performance for pages with multiple data sources.
React Server Components Architecture
// Server vs Client Component boundary // // ┌──────────────────────────────────────┐ // │ Server Component │ // │ - Fetches data directly │ // │ - Accesses DB, file system, env │ // │ - Zero client JS bundle impact │ // │ - Cannot use useState, useEffect │ // │ │ // │ ┌──────────────────────────────┐ │ // │ │ Client Component │ │ // │ │ 'use client' │ │ // │ │ - Interactive (onClick) │ │ // │ │ - Uses hooks (state, ref) │ │ // │ │ - Ships JS to browser │ │ // │ └──────────────────────────────┘ │ // └──────────────────────────────────────┘ // Server Component (default in App Router) async function ProductPage({ id }: { id: string }) { const product = await db.products.findUnique({ where: { id } }) const mdxContent = await compileMDX(product.description) return ( <article> <h1>{product.name}</h1> {mdxContent} {/* Only this ships JS to the client */} <AddToCartButton productId={id} price={product.price} /> </article> ) } // Client Component 'use client' function AddToCartButton({ productId, price }: AddToCartProps) { const [isAdding, setIsAdding] = useState(false) const addToCart = async () => { setIsAdding(true) await cartApi.add(productId) setIsAdding(false) } return ( <button onClick={addToCart} disabled={isAdding}> Add to Cart — ${price} </button> ) }
React Server Components keep data-fetching and rendering logic on the server, sending only the minimal interactive JavaScript to the client. This reduces bundle size and improves performance.
Common Mistakes
- Using CSR for SEO-critical pages like product listings or blog posts
- Applying SSR to every page when SSG or ISR would be faster and cheaper
- Not using streaming SSR when a page depends on multiple slow data sources
- Making entire pages client components when only a small interactive widget needs JavaScript
- Ignoring the hydration cost of SSR: shipping large JS bundles negates the SSR benefit
Interview Tips
- Always justify your rendering choice for each route type with concrete trade-offs
- Mention hybrid rendering: different strategies for different pages in the same app
- Discuss how streaming SSR with Suspense solves the slow-data-source problem
- Explain the Server Component boundary and when to add use client
- Know the performance metrics each strategy optimizes: TTFB, FCP, LCP, TTI