Skip to main content

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

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 approach
function 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 server
async 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 chunks

Each 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

  1. Request arrives at the server
  2. Server fetches data from databases or APIs
  3. Server renders React/component tree to HTML
  4. HTML sent to client with JavaScript bundle
  5. Client hydrates the HTML (attaches event handlers)

SSR Implementation in Next.js

Next.js provides SSR through Server Components and the App Router:

app/products/[id]/page.tsx
// 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.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
// Loader runs on server for each request
export 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:

src/pages/products/[id].astro
---
// Astro components are server-rendered by default
const { 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

  1. Build time: All pages are rendered to static HTML
  2. Deployment: Static files uploaded to CDN
  3. Request: CDN serves pre-built HTML instantly
  4. Hydration: Client attaches JavaScript for interactivity

SSG Implementation in Next.js

Next.js automatically generates static pages when possible:

app/blog/[slug]/page.tsx
// 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 time
export 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:

src/pages/blog/[slug].astro
---
// Pre-rendered at build time
export 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

  1. Initial request: Static page served (if exists)
  2. Background regeneration: After revalidation period, page regenerates
  3. Stale-while-revalidate: Old page served while new one generates
  4. On-demand revalidation: Can trigger regeneration via API

ISR Implementation in Next.js

Next.js provides ISR through the revalidate option:

app/products/[id]/page.tsx
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 time
export 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:

app/api/revalidate/route.ts
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:

app/dashboard/page.tsx
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:

app/products/page.tsx
// 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 Component
async 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:

app/page.tsx
import { Suspense } from 'react';
// Server Component - no JavaScript sent to client
async 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>;
}
// SSR
async function Page() {
const data = await fetch('...', { cache: 'no-store' });
return <div>{data}</div>;
}
// ISR
async function Page() {
const data = await fetch('...', { next: { revalidate: 60 } });
return <div>{data}</div>;
}
// Streaming
export 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 caching
export 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 configured
const 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:

src/routes/products/[id]/+page.server.ts
// SvelteKit: SSR by default, SSG with adapter-static
export 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

FeatureNext.jsRemixAstroSvelteKit
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 mismatch
function 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 SSR
function 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: false
import 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 JavaScript
const 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 everything
import { HeavyLibrary } from 'heavy-library';
function Page() {
return <HeavyLibrary />;
}
// ✅ Good: Code split and lazy load
import dynamic from 'next/dynamic';
const HeavyLibrary = dynamic(() => import('./HeavyLibrary'), {
ssr: false,
});

2. Avoid Unnecessary Re-renders:

// ❌ Bad: Causes unnecessary re-renders
function Component({ data }: { data: Data }) {
const processed = useMemo(() => processData(data), [data]);
return <div>{processed}</div>;
}
// ✅ Good: Process on server, send processed data
async 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 Vitals
import { getCLS, getFID, getFCP, getLCP, getTTFB } from "web-vitals";
function reportWebVitals(metric: any) {
// Send to analytics
console.log(metric);
}
// Measure in your app
getCLS(reportWebVitals);
getFCP(reportWebVitals);
getLCP(reportWebVitals);
getTTFB(reportWebVitals);

Performance Optimization Strategies

1. Optimize Server Response Time:

// ❌ Bad: Slow database queries
async function Page() {
const data = await slowDatabaseQuery(); // Takes 2 seconds
return <div>{data}</div>;
}
// ✅ Good: Optimize queries, use caching
async 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 components
import 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 component
import 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 → SSG
Changes occasionally → ISR
Changes frequently → SSR
Changes in real-time → SSR + WebSockets

2. User Personalization:

Same for all users → SSG/ISR
User-specific → SSR
Mix of both → Hybrid (SSG + SSR)

3. Traffic Patterns:

High traffic, same content → SSG/ISR
Low traffic, dynamic content → SSR
Variable traffic → ISR with on-demand revalidation

Real-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 layout
export default function Layout({ children }: { children: React.ReactNode }) {
return <html><body>{children}</body></html>;
}
// app/blog/[slug]/page.tsx - SSG for blog posts
async function BlogPost() {
const post = await fetchPost(); // Static
return <article>{post.content}</article>;
}
// app/dashboard/page.tsx - SSR for user dashboard
async function Dashboard() {
const user = await getCurrentUser(); // Dynamic
return <div>Welcome, {user.name}</div>;
}
// app/products/[id]/page.tsx - ISR for products
async 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:

app/products/[id]/page.tsx
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:

app/blog/[slug]/page.tsx
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.ts
export 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:

app/dashboard/page.tsx
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:

app/api/data/route.ts
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.tsx
export 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 error
function 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 request
async 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 revalidation
async 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 page
async 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 progressively
function 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 hydration
async 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 handling
async 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 fallbacks
async 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 → SSG
export async function generateStaticParams() {
return [{ slug: 'about' }, { slug: 'contact' }];
}
// Dynamic content → SSR
async function DynamicPage() {
const data = await fetch('...', { cache: 'no-store' });
return <div>{data}</div>;
}
// Semi-dynamic → ISR
async 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 caching
async 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 imports
import 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 revalidation
async 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 revalidation
revalidateTag('products'); // Revalidates all products

✅ Practice 5: Monitor Performance

Track and optimize performance metrics:

// ✅ Good: Monitor Web Vitals
import { 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 JS
async 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.