Server-Side Rendering Strategies: SSR, SSG, ISR, and Streaming Complete Guide
Master server-side rendering strategies including SSR, SSG, ISR, and streaming. Learn framework comparisons, hydration patterns, and performance optimization techniques for modern web applications.
Table of Contents
- Introduction
- Understanding Rendering Strategies
- Server-Side Rendering (SSR)
- Static Site Generation (SSG)
- Incremental Static Regeneration (ISR)
- Streaming SSR and React Server Components
- Framework Comparison: Next.js, Remix, Astro, and SvelteKit
- Hydration Strategies and Optimization
- Performance Trade-offs and Metrics
- Choosing the Right Strategy
- Real-World Implementation Patterns
- Common Pitfalls and Solutions
- Best Practices
- Conclusion
Introduction
Server-side rendering has evolved from a simple technique to a sophisticated ecosystem of strategies, each optimized for different use cases. Modern web applications face the challenge of balancing performance, SEO, user experience, and development complexity. Understanding when and how to use different rendering strategies—Server-Side Rendering (SSR), Static Site Generation (SSG), Incremental Static Regeneration (ISR), and Streaming SSR—is crucial for building fast, scalable applications.
The landscape of server-side rendering has been transformed by frameworks like Next.js, Remix, Astro, and SvelteKit, each offering unique approaches to solving the rendering problem. These frameworks abstract away much of the complexity while providing powerful features like automatic code splitting, intelligent caching, and progressive hydration.
However, choosing the wrong rendering strategy can lead to poor performance, unnecessary server costs, or compromised user experience. A blog might benefit from SSG, while a dashboard with real-time data needs SSR. An e-commerce site might use ISR for product pages, and a social media feed could leverage streaming SSR for instant perceived performance.
This comprehensive guide will walk you through each rendering strategy, explain their trade-offs, show you how to implement them with popular frameworks, and help you make informed decisions for your projects. You’ll learn about hydration patterns, performance optimization techniques, and real-world implementation patterns used by successful applications.
By the end of this guide, you’ll understand when to use each rendering strategy, how to optimize performance, and how to implement them effectively in your applications.
Understanding Rendering Strategies
Before diving into specific strategies, it’s essential to understand the fundamental concepts and how they differ from traditional client-side rendering.
Client-Side Rendering (CSR) vs Server-Side Rendering
Client-Side Rendering (CSR) is the traditional approach used by single-page applications (SPAs):
// Traditional CSR approachfunction App() { const [data, setData] = useState(null);
useEffect(() => { // Data fetched on client after page loads fetch('/api/data').then(res => res.json()).then(setData); }, []);
if (!data) return <div>Loading...</div>; return <div>{data.content}</div>;}Problems with CSR:
- ❌ Slow initial page load (blank screen until JavaScript loads)
- ❌ Poor SEO (search engines see empty HTML)
- ❌ Requires JavaScript to display content
- ❌ Slower Time to First Contentful Paint (FCP)
Server-Side Rendering (SSR) solves these issues by rendering HTML on the server:
// SSR approach - HTML rendered on serverasync function Page() { // Data fetched on server before HTML is sent const data = await fetch('https://api.example.com/data'); const json = await data.json();
return <div>{json.content}</div>; // HTML sent to client}Benefits of SSR:
- ✅ Fast initial page load (HTML arrives ready to display)
- ✅ Better SEO (search engines see full content)
- ✅ Works without JavaScript (progressive enhancement)
- ✅ Faster FCP and Largest Contentful Paint (LCP)
The Rendering Spectrum
Modern rendering strategies exist on a spectrum from fully static to fully dynamic:
Fully Static (SSG) → Partially Dynamic (ISR) → Fully Dynamic (SSR) → Streaming (Streaming SSR) ↓ ↓ ↓ ↓ Pre-built HTML Rebuild on demand Render per request Stream chunksEach strategy balances build-time vs runtime rendering, caching opportunities, and freshness of content.
Server-Side Rendering (SSR)
Server-Side Rendering (SSR) generates HTML on the server for each request. This ensures content is always fresh and personalized, making it ideal for dynamic, user-specific content.
How SSR Works
- Request arrives at the server
- Server fetches data from databases or APIs
- Server renders React/component tree to HTML
- HTML sent to client with JavaScript bundle
- Client hydrates the HTML (attaches event handlers)
SSR Implementation in Next.js
Next.js provides SSR through Server Components and the App Router:
// This is a Server Component (default in App Router)async function ProductPage({ params }: { params: { id: string } }) { // This runs on the server for each request const product = await fetch(`https://api.example.com/products/${params.id}`, { cache: 'no-store', // Ensure fresh data }).then(res => res.json());
return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <p>Price: ${product.price}</p> </div> );}
export default ProductPage;SSR Implementation in Remix
Remix uses loaders that run on the server:
// app/routes/products.$id.tsximport { json } from "@remix-run/node";import { useLoaderData } from "@remix-run/react";
// Loader runs on server for each requestexport async function loader({ params }: { params: { id: string } }) { const product = await fetch(`https://api.example.com/products/${params.id}`); return json(await product.json());}
export default function ProductPage() { const product = useLoaderData<typeof loader>();
return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> </div> );}SSR Implementation in Astro
Astro uses server endpoints and server-side rendering mode:
---// Astro components are server-rendered by defaultconst { id } = Astro.params;const product = await fetch(`https://api.example.com/products/${id}`).then( (r) => r.json(),);---
<html> <head> <title>{product.name}</title> </head> <body> <h1>{product.name}</h1> <p>{product.description}</p> </body></html>When to Use SSR
✅ Use SSR when:
- Content is highly dynamic (user-specific, real-time)
- SEO is important but content changes frequently
- You need personalized content per user
- Content depends on request headers (cookies, location)
❌ Avoid SSR when:
- Content is mostly static (wastes server resources)
- You have high traffic with identical content (use SSG/ISR)
- Build-time generation is sufficient
SSR Performance Considerations
Server Load:
- Each request requires server computation
- Can become a bottleneck under high traffic
- Requires proper caching strategies
Response Time:
- Slower than SSG (must render on each request)
- Faster than CSR (HTML ready immediately)
- Can be optimized with edge computing
// Optimize SSR with edge runtime (Next.js)export const runtime = 'edge'; // Runs on edge network
async function EdgePage() { // Runs closer to users, faster response const data = await fetchData(); return <div>{data}</div>;}Static Site Generation (SSG)
Static Site Generation (SSG) pre-renders pages at build time, generating static HTML files that can be served from a CDN. This provides the best performance and lowest server costs.
How SSG Works
- Build time: All pages are rendered to static HTML
- Deployment: Static files uploaded to CDN
- Request: CDN serves pre-built HTML instantly
- Hydration: Client attaches JavaScript for interactivity
SSG Implementation in Next.js
Next.js automatically generates static pages when possible:
// Static generation (default behavior)async function BlogPost({ params }: { params: { slug: string } }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`, { // Cache forever - this is build-time data next: { revalidate: false } }).then(res => res.json());
return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> );}
// Generate static paths at build timeexport async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return posts.map((post: { slug: string }) => ({ slug: post.slug, }));}SSG Implementation in Astro
Astro is optimized for SSG by default:
---// Pre-rendered at build timeexport async function getStaticPaths() { const posts = await fetch("https://api.example.com/posts").then((r) => r.json(), ); return posts.map((post: { slug: string }) => ({ params: { slug: post.slug }, }));}
const { slug } = Astro.params;const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json(),);---
<html> <head> <title>{post.title}</title> </head> <body> <article> <h1>{post.title}</h1> <div set:html={post.content} /> </article> </body></html>SSG with Data Fetching
For SSG, you need to fetch all data at build time:
// Next.js: getStaticProps pattern (Pages Router)export async function getStaticProps({ params }: { params: { slug: string } }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`); return { props: { post: await post.json(), }, };}
// Or with App Router (automatic SSG)async function Page() { const data = await fetch('https://api.example.com/data', { // This will be cached at build time cache: 'force-cache', }); return <div>{/* render data */}</div>;}When to Use SSG
✅ Use SSG when:
- Content is mostly static (blogs, documentation, marketing sites)
- Content changes infrequently
- You want the best possible performance
- You want to minimize server costs
- SEO is critical
❌ Avoid SSG when:
- Content is highly dynamic or user-specific
- Content changes frequently (use ISR instead)
- You need real-time data
SSG Performance Benefits
Metrics:
- TTFB (Time to First Byte): < 50ms (served from CDN)
- FCP (First Contentful Paint): < 1s
- LCP (Largest Contentful Paint): < 2.5s
Cost:
- No server computation per request
- CDN costs are minimal
- Scales infinitely without additional server costs
Incremental Static Regeneration (ISR)
Incremental Static Regeneration (ISR) combines the benefits of SSG and SSR. Pages are statically generated but can be regenerated in the background after a specified time period or on-demand.
How ISR Works
- Initial request: Static page served (if exists)
- Background regeneration: After revalidation period, page regenerates
- Stale-while-revalidate: Old page served while new one generates
- On-demand revalidation: Can trigger regeneration via API
ISR Implementation in Next.js
Next.js provides ISR through the revalidate option:
async function ProductPage({ params }: { params: { id: string } }) { const product = await fetch(`https://api.example.com/products/${params.id}`, { // Revalidate every 60 seconds next: { revalidate: 60 } }).then(res => res.json());
return ( <div> <h1>{product.name}</h1> <p>Last updated: {new Date().toLocaleString()}</p> </div> );}
// Generate common products at build timeexport async function generateStaticParams() { // Generate top 100 products at build time const products = await fetch('https://api.example.com/products/top-100') .then(r => r.json()); return products.map((p: { id: string }) => ({ id: p.id }));}On-Demand Revalidation
Trigger regeneration when content changes:
import { revalidatePath } from "next/cache";import { NextRequest } from "next/server";
export async function POST(request: NextRequest) { const { path, secret } = await request.json();
// Verify secret to prevent unauthorized revalidation if (secret !== process.env.REVALIDATION_SECRET) { return Response.json({ error: "Invalid secret" }, { status: 401 }); }
try { // Revalidate specific path revalidatePath(path); return Response.json({ revalidated: true, now: Date.now() }); } catch (err) { return Response.json({ error: "Error revalidating" }, { status: 500 }); }}
// Usage: Call from CMS webhook when content updates// POST /api/revalidate// { "path": "/products/123", "secret": "your-secret" }ISR with Fallback
Handle pages not generated at build time:
// pages/products/[id].tsx (Pages Router example)export async function getStaticPaths() { return { paths: [], // Don't generate any at build time fallback: "blocking", // Generate on first request };}
export async function getStaticProps({ params }: { params: { id: string } }) { const product = await fetch(`https://api.example.com/products/${params.id}`);
if (!product.ok) { return { notFound: true }; }
return { props: { product: await product.json(), }, revalidate: 3600, // Revalidate every hour };}When to Use ISR
✅ Use ISR when:
- You have thousands of pages (can’t generate all at build time)
- Content changes periodically but not constantly
- You want SSG performance with SSR flexibility
- You have a headless CMS that updates content
❌ Avoid ISR when:
- Content must be real-time (use SSR)
- Content never changes (use SSG)
- You need user-specific content per request
ISR Performance Characteristics
First Request (Cold):
- Generates page on-demand
- Slightly slower than SSG (but only for first request)
Subsequent Requests:
- Served from cache (SSG performance)
- Regenerates in background when stale
Revalidation Period:
- Balance between freshness and performance
- Shorter = fresher but more regeneration
- Longer = better performance but staler content
Streaming SSR and React Server Components
Streaming SSR sends HTML to the client in chunks as it’s rendered, rather than waiting for the entire page. Combined with React Server Components, this enables progressive rendering and better perceived performance.
How Streaming Works
Traditional SSR:
Server renders entire page → Sends complete HTML → Client displays[============ Wait ============] → [Display]Streaming SSR:
Server renders chunks → Streams HTML chunks → Client displays progressively[Chunk 1] → [Display] → [Chunk 2] → [Display] → [Chunk 3] → [Display]Streaming Implementation in Next.js
Next.js App Router supports streaming with Suspense:
import { Suspense } from 'react';
async function SlowComponent() { // This takes 3 seconds to fetch await new Promise(resolve => setTimeout(resolve, 3000)); const data = await fetch('https://api.example.com/slow-data').then(r => r.json()); return <div>{data.content}</div>;}
function FastComponent() { return <div>This renders immediately</div>;}
export default function DashboardPage() { return ( <div> <FastComponent /> {/* Renders immediately */} <Suspense fallback={<div>Loading slow data...</div>}> <SlowComponent /> {/* Streams when ready */} </Suspense> </div> );}React Server Components
Server Components enable streaming by running on the server:
// This is a Server Component (runs on server)async function ProductList() { const products = await fetch('https://api.example.com/products').then(r => r.json());
return ( <div> {products.map(product => ( <ProductCard key={product.id} product={product} /> ))} </div> );}
// ProductCard can also be a Server Componentasync function ProductCard({ product }: { product: Product }) { // Can fetch additional data, access databases, etc. const reviews = await fetch(`https://api.example.com/products/${product.id}/reviews`) .then(r => r.json());
return ( <div> <h2>{product.name}</h2> <p>{product.price}</p> <Reviews reviews={reviews} /> </div> );}
export default ProductList;Selective Hydration
Streaming enables selective hydration - only hydrate what’s interactive:
import { Suspense } from 'react';
// Server Component - no JavaScript sent to clientasync function StaticContent() { const data = await fetchData(); return <div>{data}</div>; // Pure HTML, no hydration needed}
// Client Component - hydrated on client'use client';function InteractiveButton() { return <button onClick={() => alert('Clicked')}>Click me</button>;}
export default function Page() { return ( <div> <Suspense fallback={<div>Loading...</div>}> <StaticContent /> {/* No hydration */} </Suspense> <InteractiveButton /> {/* Hydrated */} </div> );}When to Use Streaming SSR
✅ Use Streaming SSR when:
- You have slow data dependencies
- You want to improve perceived performance
- You have multiple independent data sources
- You’re using React Server Components
❌ Avoid Streaming SSR when:
- All data loads quickly (adds unnecessary complexity)
- You need complete HTML for SEO (some crawlers don’t handle streaming)
- You’re not using a framework that supports it
Streaming Performance Benefits
Perceived Performance:
- Users see content faster (progressive rendering)
- Better Time to First Byte (TTFB)
- Improved Largest Contentful Paint (LCP)
Real-World Example:
// Without streaming: User waits 3s for everything// With streaming: User sees header in 0.1s, content in 1s, sidebar in 3s
async function Page() { return ( <div> <Header /> {/* Renders immediately */} <Suspense fallback={<ContentSkeleton />}> <Content /> {/* Streams when ready */} </Suspense> <Suspense fallback={<SidebarSkeleton />}> <Sidebar /> {/* Streams when ready */} </Suspense> </div> );}Framework Comparison: Next.js, Remix, Astro, and SvelteKit
Each framework offers different approaches to server-side rendering. Understanding their strengths helps you choose the right tool.
Next.js
Strengths:
- ✅ Comprehensive SSR/SSG/ISR support
- ✅ Excellent React Server Components support
- ✅ Strong ecosystem and community
- ✅ Built-in optimizations (Image, Font, Script)
Rendering Options:
// SSG (default in App Router)async function Page() { const data = await fetch('...', { cache: 'force-cache' }); return <div>{data}</div>;}
// SSRasync function Page() { const data = await fetch('...', { cache: 'no-store' }); return <div>{data}</div>;}
// ISRasync function Page() { const data = await fetch('...', { next: { revalidate: 60 } }); return <div>{data}</div>;}
// Streamingexport default function Page() { return ( <Suspense fallback={<Loading />}> <AsyncComponent /> </Suspense> );}Best For: React applications needing flexibility, large teams, complex routing.
Remix
Strengths:
- ✅ Excellent data loading patterns (loaders)
- ✅ Strong form handling and mutations
- ✅ Progressive enhancement focus
- ✅ Simple mental model
Rendering Approach:
// Remix uses SSR with smart cachingexport async function loader({ request }: { request: Request }) { // Runs on server for each request const data = await fetchData(); return json(data, { headers: { 'Cache-Control': 'public, max-age=60', // HTTP caching }, });}
export default function Page() { const data = useLoaderData<typeof loader>(); return <div>{data}</div>;}Best For: Data-heavy applications, forms, progressive enhancement, simpler mental model.
Astro
Strengths:
- ✅ Minimal JavaScript by default
- ✅ Excellent SSG performance
- ✅ Framework agnostic (React, Vue, Svelte islands)
- ✅ Content-focused sites
Rendering Approach:
---// Astro: SSG by default, SSR when configuredconst data = await fetch("https://api.example.com/data").then((r) => r.json());---
<html> <body> <h1>{data.title}</h1> <!-- React component (only hydrated if needed) --> <ReactComponent client:load /> </body></html>Best For: Content sites, blogs, documentation, when you want minimal JavaScript.
SvelteKit
Strengths:
- ✅ Excellent performance (small bundle sizes)
- ✅ Great developer experience
- ✅ Built-in SSR/SSG support
- ✅ Simple, intuitive API
Rendering Approach:
// SvelteKit: SSR by default, SSG with adapter-staticexport async function load({ params }: { params: { id: string } }) { const product = await fetch(`https://api.example.com/products/${params.id}`); return { product: await product.json(), };}
// src/routes/products/[id]/+page.svelte<script> export let data; const { product } = data;</script>
<h1>{product.name}</h1>Best For: Performance-critical applications, Svelte developers, smaller bundle sizes.
Comparison Table
| Feature | Next.js | Remix | Astro | SvelteKit |
|---|---|---|---|---|
| SSR | ✅ Excellent | ✅ Excellent | ✅ Good | ✅ Excellent |
| SSG | ✅ Excellent | ⚠️ Limited | ✅ Excellent | ✅ Excellent |
| ISR | ✅ Built-in | ❌ No | ⚠️ Partial | ⚠️ Partial |
| Streaming | ✅ Excellent | ✅ Good | ⚠️ Limited | ✅ Good |
| React Support | ✅ Native | ✅ Good | ✅ Islands | ❌ No |
| Bundle Size | ⚠️ Medium | ⚠️ Medium | ✅ Small | ✅ Very Small |
| Learning Curve | ⚠️ Steep | ✅ Moderate | ✅ Easy | ✅ Moderate |
Hydration Strategies and Optimization
Hydration is the process of attaching JavaScript event handlers and state to server-rendered HTML. Poor hydration can negate SSR performance benefits.
Understanding Hydration
// Server renders this HTML:<div id="root"> <button>Click me</button></div>
// Client hydrates by attaching event handlers:document.getElementById('root').addEventListener('click', handleClick);Hydration Mismatch Errors
Common cause: Server and client rendering different HTML:
// ❌ Bad: Causes hydration mismatchfunction Component() { const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
// Server: renders <div>Server</div> // Client: renders <div>Client</div> return <div>{mounted ? 'Client' : 'Server'}</div>;}
// ✅ Good: Avoid client-only content during SSRfunction Component() { const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null; // Or return server-safe content return <div>Client-only content</div>;}Progressive Hydration
Hydrate components as they become visible:
// Next.js: Use dynamic imports with ssr: falseimport dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), { ssr: false, // Don't render on server loading: () => <div>Loading...</div>,});
function Page() { return ( <div> <StaticContent /> {/* Rendered on server */} <HeavyComponent /> {/* Loaded and hydrated on client */} </div> );}Selective Hydration
Only hydrate interactive components:
// Server Component (no hydration)async function StaticContent() { const data = await fetchData(); return <div>{data}</div>; // Pure HTML}
// Client Component (hydrated)'use client';function InteractiveButton() { return <button onClick={handleClick}>Click</button>;}
function Page() { return ( <div> <StaticContent /> {/* No JS needed */} <InteractiveButton /> {/* Hydrated */} </div> );}Partial Hydration (Islands Architecture)
Astro’s islands architecture only hydrates specific components:
---// Server-rendered, no JavaScriptconst data = await fetchData();---
<div> <h1>{data.title}</h1>
<!-- Only this component is hydrated --> <ReactComponent client:load />
<!-- This component hydrates on visibility --> <VueComponent client:visible />
<!-- This component hydrates on interaction --> <SvelteComponent client:idle /></div>Hydration Optimization Techniques
1. Minimize Hydration Payload:
// ❌ Bad: Large bundle hydrates everythingimport { HeavyLibrary } from 'heavy-library';
function Page() { return <HeavyLibrary />;}
// ✅ Good: Code split and lazy loadimport dynamic from 'next/dynamic';
const HeavyLibrary = dynamic(() => import('./HeavyLibrary'), { ssr: false,});2. Avoid Unnecessary Re-renders:
// ❌ Bad: Causes unnecessary re-rendersfunction Component({ data }: { data: Data }) { const processed = useMemo(() => processData(data), [data]); return <div>{processed}</div>;}
// ✅ Good: Process on server, send processed dataasync function ServerComponent() { const data = await fetchData(); const processed = processData(data); // Process on server return <ClientComponent data={processed} />;}3. Use Server Components When Possible:
// ❌ Bad: Client component when server would work'use client';function BlogPost({ post }: { post: Post }) { return <article>{post.content}</article>; // No interactivity needed}
// ✅ Good: Server component (no hydration)async function BlogPost({ slug }: { slug: string }) { const post = await fetchPost(slug); return <article>{post.content}</article>; // Rendered on server}Performance Trade-offs and Metrics
Understanding performance metrics helps you measure and optimize your rendering strategy choice.
Key Performance Metrics
Time to First Byte (TTFB):
- SSG: < 50ms (CDN)
- ISR: < 50ms (cached) or ~200ms (regenerating)
- SSR: 200-500ms (server computation)
- Streaming SSR: 100-200ms (first chunk)
First Contentful Paint (FCP):
- SSG: < 1s
- ISR: < 1s (cached)
- SSR: 1-2s
- Streaming SSR: 0.5-1s
Largest Contentful Paint (LCP):
- SSG: < 2.5s
- ISR: < 2.5s (cached)
- SSR: 2-4s
- Streaming SSR: 1-2.5s
Cumulative Layout Shift (CLS):
- Affected by hydration strategy
- Progressive hydration reduces CLS
- Avoid layout shifts during hydration
Measuring Performance
// Next.js: Built-in Web Vitalsimport { getCLS, getFID, getFCP, getLCP, getTTFB } from "web-vitals";
function reportWebVitals(metric: any) { // Send to analytics console.log(metric);}
// Measure in your appgetCLS(reportWebVitals);getFCP(reportWebVitals);getLCP(reportWebVitals);getTTFB(reportWebVitals);Performance Optimization Strategies
1. Optimize Server Response Time:
// ❌ Bad: Slow database queriesasync function Page() { const data = await slowDatabaseQuery(); // Takes 2 seconds return <div>{data}</div>;}
// ✅ Good: Optimize queries, use cachingasync function Page() { const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 }, // Cache for 60 seconds }); return <div>{data}</div>;}2. Reduce JavaScript Bundle Size:
// Use dynamic imports for heavy componentsimport dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'), { ssr: false, // Don't include in SSR bundle});
function Dashboard() { return <Chart />; // Loaded only when needed}3. Optimize Images:
// Next.js Image componentimport Image from 'next/image';
function Page() { return ( <Image src="/hero.jpg" alt="Hero" width={800} height={600} priority // Load immediately placeholder="blur" // Show blur while loading /> );}Choosing the Right Strategy
Selecting the appropriate rendering strategy depends on your content type, update frequency, and performance requirements.
Decision Framework
1. Content Update Frequency:
Never changes → SSGChanges occasionally → ISRChanges frequently → SSRChanges in real-time → SSR + WebSockets2. User Personalization:
Same for all users → SSG/ISRUser-specific → SSRMix of both → Hybrid (SSG + SSR)3. Traffic Patterns:
High traffic, same content → SSG/ISRLow traffic, dynamic content → SSRVariable traffic → ISR with on-demand revalidationReal-World Examples
Blog/Content Site:
- Strategy: SSG
- Reason: Content changes infrequently, same for all users
- Implementation: Generate all posts at build time
E-commerce Product Pages:
- Strategy: ISR
- Reason: Thousands of products, prices change occasionally
- Implementation: Generate top products at build, others on-demand with revalidation
User Dashboard:
- Strategy: SSR
- Reason: Highly personalized, real-time data
- Implementation: Render per request with user-specific data
Social Media Feed:
- Strategy: Streaming SSR
- Reason: Real-time updates, progressive loading improves UX
- Implementation: Stream feed items as they load
Marketing Landing Page:
- Strategy: SSG
- Reason: Static content, maximum performance
- Implementation: Pre-render at build time
Hybrid Approaches
Many applications use multiple strategies:
// app/layout.tsx - SSG for layoutexport default function Layout({ children }: { children: React.ReactNode }) { return <html><body>{children}</body></html>;}
// app/blog/[slug]/page.tsx - SSG for blog postsasync function BlogPost() { const post = await fetchPost(); // Static return <article>{post.content}</article>;}
// app/dashboard/page.tsx - SSR for user dashboardasync function Dashboard() { const user = await getCurrentUser(); // Dynamic return <div>Welcome, {user.name}</div>;}
// app/products/[id]/page.tsx - ISR for productsasync function ProductPage() { const product = await fetchProduct({ next: { revalidate: 3600 }, // Revalidate hourly }); return <div>{product.name}</div>;}Real-World Implementation Patterns
Here are common patterns used in production applications.
Pattern 1: SSG with Client-Side Data Fetching
Combine SSG for initial content with client-side fetching for dynamic parts:
async function ProductPage({ params }: { params: { id: string } }) { // SSG: Fetch product data at build time const product = await fetchProduct(params.id);
return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> {/* Client component fetches reviews on client */} <Reviews productId={params.id} /> </div> );}
// app/components/Reviews.tsx'use client';import { useQuery } from '@tanstack/react-query';
function Reviews({ productId }: { productId: string }) { const { data: reviews } = useQuery({ queryKey: ['reviews', productId], queryFn: () => fetchReviews(productId), });
return ( <div> {reviews?.map(review => ( <div key={review.id}>{review.text}</div> ))} </div> );}Pattern 2: ISR with On-Demand Revalidation
Use ISR with webhook-triggered revalidation:
async function BlogPost({ params }: { params: { slug: string } }) { const post = await fetch(`https://cms.example.com/posts/${params.slug}`, { next: { revalidate: 3600 }, // Revalidate every hour }).then(r => r.json());
return <article>{post.content}</article>;}
// app/api/revalidate/route.tsexport async function POST(request: Request) { const { slug, secret } = await request.json();
if (secret !== process.env.CMS_SECRET) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); }
revalidatePath(`/blog/${slug}`); return Response.json({ revalidated: true });}
// CMS webhook calls: POST /api/revalidate// { "slug": "my-post", "secret": "..." }Pattern 3: Streaming with Suspense Boundaries
Use streaming for pages with multiple data sources:
import { Suspense } from 'react';
async function UserProfile() { const user = await fetchUser(); // Fast return <div>{user.name}</div>;}
async function RecentActivity() { await new Promise(resolve => setTimeout(resolve, 2000)); // Slow const activity = await fetchActivity(); return <div>{activity.items.map(item => <div key={item.id}>{item.text}</div>)}</div>;}
async function Recommendations() { await new Promise(resolve => setTimeout(resolve, 1500)); // Slow const recs = await fetchRecommendations(); return <div>{recs.items.map(rec => <div key={rec.id}>{rec.title}</div>)}</div>;}
export default function Dashboard() { return ( <div> <Suspense fallback={<div>Loading profile...</div>}> <UserProfile /> {/* Renders first */} </Suspense> <Suspense fallback={<div>Loading activity...</div>}> <RecentActivity /> {/* Streams when ready */} </Suspense> <Suspense fallback={<div>Loading recommendations...</div>}> <Recommendations /> {/* Streams when ready */} </Suspense> </div> );}Pattern 4: Edge Runtime for Global Performance
Use edge runtime for faster global response times:
export const runtime = 'edge'; // Runs on edge network
export async function GET() { // Runs closer to users worldwide const data = await fetchData(); return Response.json(data);}
// app/products/[id]/page.tsxexport const runtime = 'edge';
async function ProductPage({ params }: { params: { id: string } }) { const product = await fetchProduct(params.id); return <div>{product.name}</div>;}Common Pitfalls and Solutions
Avoid these common mistakes when implementing server-side rendering.
❌ Pitfall 1: Hydration Mismatches
Problem: Server and client render different HTML.
// ❌ Bad: Causes hydration errorfunction Component() { const isClient = typeof window !== 'undefined'; return <div>{isClient ? 'Client' : 'Server'}</div>;}Solution: Use proper client-side checks or avoid client-only logic during SSR:
// ✅ Good: Avoid hydration mismatch'use client';function Component() { const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null; return <div>Client-only content</div>;}❌ Pitfall 2: Not Caching Appropriately
Problem: Regenerating pages unnecessarily.
// ❌ Bad: No caching, regenerates every requestasync function Page() { const data = await fetch('https://api.example.com/data', { cache: 'no-store', // Always fetches fresh }); return <div>{data}</div>;}Solution: Use appropriate caching strategies:
// ✅ Good: Cache with revalidationasync function Page() { const data = await fetch('https://api.example.com/data', { next: { revalidate: 3600 }, // Cache for 1 hour }); return <div>{data}</div>;}❌ Pitfall 3: Blocking on Slow Data
Problem: Page waits for slow data before rendering anything.
// ❌ Bad: Blocks entire pageasync function Page() { const slowData = await slowFetch(); // Takes 3 seconds const fastData = await fastFetch(); // Takes 0.1 seconds
return ( <div> <FastContent data={fastData} /> <SlowContent data={slowData} /> </div> );}Solution: Use streaming and Suspense:
// ✅ Good: Streams content progressivelyfunction Page() { return ( <div> <FastContent /> <Suspense fallback={<SlowContentSkeleton />}> <SlowContent /> </Suspense> </div> );}❌ Pitfall 4: Over-hydrating
Problem: Sending unnecessary JavaScript to client.
// ❌ Bad: Client component when server would work'use client';function StaticContent({ text }: { text: string }) { return <div>{text}</div>; // No interactivity needed}Solution: Use Server Components when possible:
// ✅ Good: Server component, no hydrationasync function StaticContent() { const data = await fetchData(); return <div>{data.text}</div>; // Rendered on server}❌ Pitfall 5: Ignoring Error Boundaries
Problem: Errors break entire page rendering.
// ❌ Bad: No error handlingasync function Page() { const data = await fetch('https://api.example.com/data'); return <div>{data.content}</div>; // Crashes if fetch fails}Solution: Implement proper error handling:
// ✅ Good: Error boundaries and fallbacksasync function Page() { try { const data = await fetch('https://api.example.com/data'); return <div>{data.content}</div>; } catch (error) { return <div>Error loading content</div>; }}
// Or use error.tsx in Next.js App Router// app/products/[id]/error.tsx'use client';export default function Error({ error, reset }: { error: Error; reset: () => void }) { return ( <div> <h2>Something went wrong!</h2> <button onClick={reset}>Try again</button> </div> );}Best Practices
Follow these best practices for optimal server-side rendering implementations.
✅ Practice 1: Use the Right Strategy for Each Page
Match rendering strategy to content characteristics:
// Static content → SSGexport async function generateStaticParams() { return [{ slug: 'about' }, { slug: 'contact' }];}
// Dynamic content → SSRasync function DynamicPage() { const data = await fetch('...', { cache: 'no-store' }); return <div>{data}</div>;}
// Semi-dynamic → ISRasync function SemiDynamicPage() { const data = await fetch('...', { next: { revalidate: 3600 } }); return <div>{data}</div>;}✅ Practice 2: Optimize Data Fetching
Fetch data efficiently and cache appropriately:
// ✅ Good: Parallel fetching, proper cachingasync function Page() { // Fetch in parallel const [user, posts, comments] = await Promise.all([ fetchUser(), fetchPosts(), fetchComments(), ]);
return ( <div> <UserProfile user={user} /> <PostList posts={posts} /> <CommentList comments={comments} /> </div> );}✅ Practice 3: Minimize JavaScript Bundle
Only send JavaScript that’s needed:
// ✅ Good: Code splitting and dynamic importsimport dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), { ssr: false, loading: () => <ChartSkeleton />,});
function Dashboard() { return ( <div> <LightContent /> <HeavyChart /> {/* Loaded only when needed */} </div> );}✅ Practice 4: Implement Proper Caching
Use appropriate cache headers and strategies:
// ✅ Good: ISR with appropriate revalidationasync function ProductPage({ params }: { params: { id: string } }) { const product = await fetch(`https://api.example.com/products/${params.id}`, { next: { revalidate: 3600, // Revalidate every hour tags: ['products'], // Tag for on-demand revalidation }, });
return <div>{product.name}</div>;}
// On-demand revalidationrevalidateTag('products'); // Revalidates all products✅ Practice 5: Monitor Performance
Track and optimize performance metrics:
// ✅ Good: Monitor Web Vitalsimport { getCLS, getFCP, getLCP, getTTFB } from "web-vitals";
function reportWebVitals(metric: any) { // Send to analytics if (typeof window !== "undefined") { // Track in your analytics service analytics.track("web-vital", { name: metric.name, value: metric.value, id: metric.id, }); }}
getCLS(reportWebVitals);getFCP(reportWebVitals);getLCP(reportWebVitals);getTTFB(reportWebVitals);✅ Practice 6: Progressive Enhancement
Ensure core functionality works without JavaScript:
// ✅ Good: Progressive enhancement// Server-rendered form works without JSasync function ContactForm() { return ( <form action="/api/contact" method="POST"> <input name="email" type="email" required /> <textarea name="message" required /> <button type="submit">Send</button> </form> );}
// Enhance with client-side validation'use client';function EnhancedForm() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); // Client-side validation and submission };
return <form onSubmit={handleSubmit}>...</form>;}Conclusion
Server-side rendering strategies have evolved significantly, offering developers powerful tools to build fast, SEO-friendly, and user-friendly web applications. Understanding the differences between SSR, SSG, ISR, and streaming SSR—and when to use each—is crucial for making informed architectural decisions.
Key Takeaways:
- SSG provides the best performance for static content and should be your default choice when content doesn’t change frequently
- ISR bridges the gap between SSG and SSR, offering static performance with dynamic content capabilities
- SSR is essential for personalized, real-time content that varies per user or request
- Streaming SSR improves perceived performance by sending content progressively as it’s rendered
- Framework choice matters—Next.js offers the most comprehensive options, while Remix, Astro, and SvelteKit each excel in specific scenarios
- Hydration optimization is critical—minimize JavaScript, use Server Components, and implement progressive hydration
- Performance monitoring helps you measure and optimize your rendering strategy choices
The best applications often use a hybrid approach, combining multiple rendering strategies based on the specific needs of each page or route. A marketing site might use SSG, product pages might use ISR, user dashboards might use SSR, and feeds might use streaming SSR.
As you implement server-side rendering in your applications, remember to:
- Measure performance using Web Vitals and other metrics
- Optimize data fetching with proper caching and parallel requests
- Minimize JavaScript by using Server Components and code splitting
- Handle errors gracefully with error boundaries and fallbacks
- Monitor and iterate based on real-world performance data
For more on related topics, check out our guides on React Server Components, web performance optimization, and React performance optimization.
Whether you’re building a content site, e-commerce platform, or dynamic web application, choosing the right rendering strategy and implementing it effectively will significantly impact your application’s performance, user experience, and success.