Server-Side Rendering with React: SSR, SSG, and ISR Explained
Modern web applications demand fast initial page loads, excellent SEO, and a smooth user experience. While client-side rendering (CSR) powered by React has dominated the SPA era, it comes with trade-offs: blank screens on first paint, poor crawlability for search engines, and slower Time to First Contentful Paint (TTFP). Server-side rendering strategies solve these problems by moving rendering work to the server. In this tutorial, we'll explore three core rendering strategies — SSR, SSG, and ISR — and learn how to implement them with React and Next.js.
Understanding the Rendering Spectrum
Before diving into implementation, it's important to understand where each strategy fits on the rendering spectrum:
- CSR (Client-Side Rendering): The browser downloads an empty HTML shell and JavaScript, then renders the UI. Best for highly interactive apps behind authentication.
- SSR (Server-Side Rendering): The server generates fresh HTML on every request. Best for personalized, dynamic content.
- SSG (Static Site Generation): HTML is generated once at build time and reused for every request. Best for content that rarely changes.
- ISR (Incremental Static Regeneration): Static pages are regenerated in the background at a configurable interval. Best for large sites that need fresh content without rebuilding.
Next.js is the most popular framework for implementing these strategies in React, so we'll use it throughout this tutorial.
Why Rendering Strategy Matters
Choosing the right rendering strategy affects three critical metrics:
- Performance: SSG serves pre-built HTML from a CDN, delivering the fastest possible TTFB. SSR adds server computation time but still beats CSR for first paint.
- SEO: Search engines can index fully rendered HTML from SSR and SSG. CSR content may not be crawled reliably.
- Scalability: SSG and ISR reduce server load because pages are cached. SSR requires server compute on every request, which can become expensive at scale.
- Freshness: SSR always serves the latest data. SSG serves stale data until the next build. ISR balances both by regenerating pages periodically.
Setting Up a Next.js Project
Let's start by creating a new Next.js project. We'll use the App Router, which is the recommended approach for modern Next.js applications.
npx create-next-app@latest rendering-demo
cd rendering-demo
npm run dev
Next.js 13+ with the App Router uses file-based routing inside the app/ directory. Each rendering strategy is controlled by how you structure your components and which functions you export.
Static Site Generation (SSG)
SSG is the default behavior in the Next.js App Router. Any page that doesn't use dynamic functions like cookies(), headers(), or searchParams is automatically statically generated at build time.
Here's a simple SSG page that fetches blog posts at build time:
// app/blog/page.tsx
import { getPosts } from '@/lib/api';
export const metadata = {
title: 'Blog Posts',
description: 'Read our latest articles',
};
export default async function BlogPage() {
const posts = await getPosts();
return (
<main>
<h1>Blog Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}
For dynamic routes with SSG, you use generateStaticParams to pre-render specific paths at build time:
// app/blog/[slug]/page.tsx
import { getPost, getPostSlugs } from '@/lib/api';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const slugs = await getPostSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
When you run npm run build, Next.js generates static HTML files for every slug returned by generateStaticParams. These files are served instantly from the CDN edge.
Server-Side Rendering (SSR)
SSR generates HTML on the server for every incoming request. In the App Router, SSR is triggered automatically when a page uses dynamic functions that depend on request-specific data.
Here's an example of a dashboard page that renders user-specific data on every request:
// app/dashboard/page.tsx
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session');
if (!sessionToken) {
redirect('/login');
}
const userAgent = (await headers()).get('user-agent') || '';
const user = await fetchCurrentUser(sessionToken.value);
const stats = await fetchUserStats(user.id);
return (
<main>
<h1>Welcome, {user.name}</h1>
<p>You are viewing this from: {userAgent}</p>
<section>
<h2>Your Stats</h2>
<ul>
<li>Posts: {stats.posts}</li>
<li>Followers: {stats.followers}</li>
<li>Engagement: {stats.engagementRate}%</li>
</ul>
</section>
</main>
);
}
async function fetchCurrentUser(token: string) {
const res = await fetch(`${process.env.API_URL}/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
async function fetchUserStats(userId: string) {
const res = await fetch(`${process.env.API_URL}/stats/${userId}`, {
cache: 'no-store',
});
return res.json();
}
The dynamic = 'force-dynamic' export explicitly tells Next.js to always render this page on the server at request time. The cache: 'no-store' option on fetch ensures data is never cached, so users always see fresh information.
Incremental Static Regeneration (ISR)
ISR combines the performance benefits of SSG with the freshness of SSR. Pages are generated statically, but Next.js regenerates them in the background when a request comes in after a specified time interval. This means users might see a slightly stale page on the first request after the interval expires, but the next request will serve the freshly regenerated version.
In the App Router, ISR is configured using the revalidate option on fetch calls or via a route segment config:
// app/products/page.tsx
import { getProducts } from '@/lib/api';
// Revalidate this page every 60 seconds
export const revalidate = 60;
export default async function ProductsPage() {
const products = await getProducts();
return (
<main>
<h1>Products</h1>
<div className="grid">
{products.map((product) => (
<article key={product.id}>
<h2>{product.name}</h2>
<p>{product.description}</p>
<span>${product.price}</span>
</article>
))}
</div>
</main>
);
}
For dynamic routes with ISR, combine generateStaticParams with revalidate:
// app/products/[id]/page.tsx
import { getProduct, getAllProductIds } from '@/lib/api';
import { notFound } from 'next/navigation';
export const revalidate = 300; // Revalidate every 5 minutes
export async function generateStaticParams() {
const ids = await getAllProductIds();
return ids.map((id) => ({ id }));
}
export default async function ProductDetailPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p className="price">${product.price}</p>
<p>Last updated: {new Date(product.updatedAt).toLocaleString()}</p>
</article>
);
}
You can also trigger on-demand revalidation using the Next.js revalidation API. This is useful when you want to regenerate a page immediately after content changes in your CMS:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { path, tag } = body;
if (tag) {
revalidateTag(tag);
} else if (path) {
revalidatePath(path);
} else {
revalidatePath('/', 'layout');
}
return NextResponse.json({ revalidated: true, now: Date.now() });
}
To use tag-based revalidation, tag your fetch calls:
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
});
When your CMS sends a webhook to /api/revalidate with { "tag": "products" }, all pages using that tagged fetch will be regenerated on the next request.
Combining Strategies in a Single App
One of the most powerful features of Next.js is that you can mix rendering strategies within the same application. Different routes can use different strategies based on their requirements:
// app/layout.tsx — Statically generated shell
import './globals.css';
export const metadata = {
title: 'My App',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/products">Products</a>
<a href="/dashboard">Dashboard</a>
</nav>
{children}
</body>
</html>
);
}
/blogand/blog/[slug]use SSG — content changes only on rebuild./productsand/products/[id]use ISR — content refreshes every few minutes./dashboarduses SSR — personalized data on every request./(homepage) uses SSG with ISR for the featured content section.
Best Practices
Follow these guidelines to get the most out of each rendering strategy:
- Default to SSG. Static generation is the fastest and cheapest option. Use it for any page that doesn't require per-request personalization.
- Use ISR when data changes periodically. If your content updates every few minutes or hours, ISR gives you near-SSG performance with acceptable freshness. Tune the
revalidateinterval to match your content update frequency. - Reserve SSR for truly dynamic content. User dashboards, search results, and authenticated pages benefit from SSR. Avoid SSR for public marketing pages where SSG or ISR would suffice.
- Cache aggressively at the data layer. Even with SSR, you can cache upstream API responses using
next.revalidateon fetch calls. This reduces server load without sacrificing the ability to render on the server. - Use streaming with Suspense. In the App Router, wrap slow components in
<Suspense>to stream HTML to the client as it becomes available. This improves perceived performance for SSR pages with slow data dependencies. - Monitor Core Web Vitals. Track LCP, FID, and CLS for each route. If an SSR page has poor LCP, consider moving static parts to SSG and streaming the dynamic parts.
- Handle errors gracefully. Use
error.tsxboundary files to catch rendering errors and show fallback UI without crashing the entire page. - Secure revalidation endpoints. Always protect on-demand revalidation webhooks with a secret token to prevent unauthorized cache invalidation.
Streaming with Suspense for Better SSR Performance
One challenge with SSR is that the server waits for all data to load before sending any HTML. Streaming solves this by sending HTML in chunks as data becomes available. Here's how to implement it:
// app/feed/page.tsx
import { Suspense } from 'react';
export const dynamic = 'force-dynamic';
async function RecentPosts() {
const posts = await fetch('https://api.example.com/posts/recent', {
cache: 'no-store',
}).then((res) => res.json());
return (
<section>
<h2>Recent Posts</h2>
{posts.map((post: any) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</article>
))}
</section>
);
}
async function TrendingTopics() {
const topics = await fetch('https://api.example.com/topics/trending', {
cache: 'no-store',
}).then((res) => res.json());
return (
<aside>
<h2>Trending</h2>
<ul>
{topics.map((topic: any) => (
<li key={topic.id}>{topic.name}</li>
))}
</ul>
</aside>
);
}
export default function FeedPage() {
return (
<main>
<h1>Feed</h1>
<Suspense fallback={<p>Loading recent posts...</p>}>
<RecentPosts />
</Suspense>
<Suspense fallback={<p>Loading trending topics...</p>}>
<TrendingTopics />
</Suspense>
</main>
);
}
With this setup, the server sends the page shell and the loading fallbacks immediately, then streams the actual content as each data fetch completes. Users see content faster, even if some sections take longer to load.
Conclusion
Server-side rendering strategies are essential tools for building fast, SEO-friendly React applications. SSG delivers the best performance for static content, SSR provides fresh personalized HTML on every request, and ISR bridges the gap by regenerating static pages in the background. The key to success is understanding your content's update frequency and user expectations, then choosing the right strategy for each route. Next.js makes it straightforward to combine all three approaches within a single application, so you can optimize every page individually. Start with SSG as your default, add ISR where content changes periodically, and reserve SSR for pages that truly need per-request rendering. By following these patterns and best practices, you'll build React applications that are fast, scalable, and maintainable.