The Complete Guide to Server-Side Rendering in Next.js
Master Server-Side Rendering with Next.js. Learn SSR, SSG, ISR, and the App Router to build lightning-fast, SEO-friendly web applications.
Introduction
Next.js has revolutionized how we build React applications by bringing server-side rendering to the masses. With the introduction of the App Router and React Server Components, the framework has evolved to offer even more powerful rendering strategies.
Why Server-Side Rendering?
SSR improves SEO, reduces time to first byte, and provides better performance on slower devices by sending pre-rendered HTML to the client.
Rendering Strategies Overview
Next.js offers multiple rendering strategies, each suited for different use cases:
Server-Side Rendering (SSR)
Renders pages on each request. Perfect for dynamic, personalized content.
- ✅ Always fresh data
- ✅ Personalized content
- ❌ Slower than static
Static Site Generation (SSG)
Generates pages at build time. Ideal for content that doesn't change often.
- ✅ Lightning fast
- ✅ Great for SEO
- ❌ Stale until rebuild
Incremental Static Regeneration (ISR)
Updates static pages after deployment without full rebuild.
- ✅ Fast like static
- ✅ Fresh content
- ✅ Best of both worlds
Client-Side Rendering (CSR)
Renders in the browser. Good for authenticated, interactive sections.
- ✅ Highly interactive
- ✅ No server load
- ❌ Slower initial load
App Router vs Pages Router
The new App Router (Next.js 13+) uses React Server Components by default:
// This is a Server Component by default
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
cache: 'no-store' // SSR
});
const posts = await data.json();
return (
<div>
<h1>Latest Posts</h1>
{posts.map(post => (
<Article key={post.id} post={post} />
))}
</div>
);
}Benefits:
- Server Components by default
- Improved data fetching
- Nested layouts
- Streaming and Suspense
- Better performance
The classic Pages Router uses getServerSideProps:
import { GetServerSideProps } from 'next';
interface Props {
posts: Post[];
}
export default function Home({ posts }: Props) {
return (
<div>
<h1>Latest Posts</h1>
{posts.map(post => (
<Article key={post.id} post={post} />
))}
</div>
);
}
export const getServerSideProps: GetServerSideProps = async () => {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts },
};
};When to use:
- Existing projects
- Gradual migration
- Familiar patterns
| Feature | App Router | Pages Router |
|---|---|---|
| Server Components | ✅ Default | ❌ No |
| Data Fetching | Async/await | getServerSideProps |
| Layouts | ✅ Nested | Single _app.tsx |
| Loading UI | ✅ loading.tsx | Manual |
| Error Handling | ✅ error.tsx | Manual |
| Streaming | ✅ Native | ❌ No |
| Performance | ⚡ Better | ✓ Good |
| Learning Curve | Moderate | Easy |
Migration Note
You can use both routers in the same project during migration, but they can't share the same route. Plan your migration strategy carefully.
Server-Side Rendering (SSR)
How SSR Works
User Requests Page
Browser sends request to Next.js server
Server Fetches Data
Server fetches data from API/database
Server Renders HTML
React renders component to HTML
HTML Sent to Client
Pre-rendered HTML sent to browser
Hydration
React makes page interactive
Implementing SSR
interface PageProps {
params: { id: string };
}
// Force dynamic rendering (SSR)
export const dynamic = 'force-dynamic';
export default async function ProductPage({ params }: PageProps) {
// This runs on the server for each request
const product = await fetch(
`https://api.example.com/products/${params.id}`,
{ cache: 'no-store' }
).then(res => res.json());
const reviews = await fetch(
`https://api.example.com/products/${params.id}/reviews`,
{ cache: 'no-store' }
).then(res => res.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<Reviews data={reviews} />
</div>
);
}
// Generate metadata server-side
export async function generateMetadata({ params }: PageProps) {
const product = await fetch(
`https://api.example.com/products/${params.id}`
).then(res => res.json());
return {
title: product.name,
description: product.description,
};
}import { GetServerSideProps } from 'next';
interface Props {
product: Product;
reviews: Review[];
}
export default function ProductPage({ product, reviews }: Props) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<Reviews data={reviews} />
</div>
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params!;
// Fetch product and reviews in parallel
const [productRes, reviewsRes] = await Promise.all([
fetch(`https://api.example.com/products/${id}`),
fetch(`https://api.example.com/products/${id}/reviews`),
]);
const [product, reviews] = await Promise.all([
productRes.json(),
reviewsRes.json(),
]);
return {
props: {
product,
reviews,
},
};
};import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
// Redirect if not authenticated
if (!session) {
redirect('/login');
}
// Fetch user-specific data
const userData = await fetch(
`https://api.example.com/users/${session.user.id}/data`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
cache: 'no-store',
}
).then(res => res.json());
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<UserData data={userData} />
</div>
);
}Parallel Data Fetching
Always fetch independent data in parallel using Promise.all() to minimize server response time.
Static Site Generation (SSG)
Perfect for content that doesn't change frequently, like blog posts or product pages.
Project Structure
Implementation
import { notFound } from 'next/navigation';
interface PageProps {
params: { slug: string };
}
// Generate static pages at build time
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return posts.map((post: Post) => ({
slug: post.slug,
}));
}
// Static by default in App Router
export default async function BlogPost({ params }: PageProps) {
const post = await fetch(
`https://api.example.com/posts/${params.slug}`
).then(res => res.json());
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// Generate metadata for SEO
export async function generateMetadata({ params }: PageProps) {
const post = await fetch(
`https://api.example.com/posts/${params.slug}`
).then(res => res.json());
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}import { GetStaticProps, GetStaticPaths } from 'next';
interface Props {
post: Post;
}
export default function BlogPost({ post }: Props) {
return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
const paths = posts.map((post: Post) => ({
params: { slug: post.slug },
}));
return {
paths,
fallback: 'blocking', // or false or true
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetch(
`https://api.example.com/posts/${params!.slug}`
).then(res => res.json());
if (!post) {
return {
notFound: true,
};
}
return {
props: { post },
revalidate: 3600, // ISR: Revalidate every hour
};
};import { compileMDX } from 'next-mdx-remote/rsc';
import fs from 'fs/promises';
import path from 'path';
const postsDirectory = path.join(process.cwd(), 'content');
export async function generateStaticParams() {
const files = await fs.readdir(postsDirectory);
return files
.filter(file => file.endsWith('.mdx'))
.map(file => ({
slug: file.replace(/\.mdx$/, ''),
}));
}
export default async function BlogPost({ params }: PageProps) {
const filePath = path.join(postsDirectory, `${params.slug}.mdx`);
const source = await fs.readFile(filePath, 'utf8');
const { content, frontmatter } = await compileMDX({
source,
options: { parseFrontmatter: true },
components: {
// Custom components
CodeBlock,
Image: CustomImage,
},
});
return (
<article>
<h1>{frontmatter.title}</h1>
<time>{frontmatter.date}</time>
{content}
</article>
);
}Fallback Options
false: Only generated paths accessible, 404 for otherstrue: Generate on-demand, show loading state'blocking': Generate on-demand, wait before showing page
Incremental Static Regeneration (ISR)
ISR combines the best of SSG and SSR: static speed with dynamic freshness.
How ISR Works
Initial Build
Pages generated at build time like SSG
Serve Stale
Cached page served instantly
Revalidation
After specified time, regenerate in background
Update Cache
New version cached and served to subsequent users
Implementation
interface PageProps {
params: { id: string };
}
export const revalidate = 60; // Revalidate every 60 seconds
export default async function ProductPage({ params }: PageProps) {
const product = await fetch(
`https://api.example.com/products/${params.id}`,
{
next: {
revalidate: 60, // Can override per-request
tags: ['products'] // For on-demand revalidation
}
}
).then(res => res.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}</p>
<p>Last updated: {new Date().toLocaleString()}</p>
</div>
);
}
export async function generateStaticParams() {
const products = await fetch('https://api.example.com/products')
.then(res => res.json());
// Generate first 100 products at build time
return products.slice(0, 100).map((product: Product) => ({
id: product.id,
}));
}interface Props {
product: Product;
}
export default function ProductPage({ product }: Props) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}</p>
<p>Last updated: {new Date().toLocaleString()}</p>
</div>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const products = await fetch('https://api.example.com/products')
.then(res => res.json());
const paths = products.slice(0, 100).map((product: Product) => ({
params: { id: product.id },
}));
return {
paths,
fallback: 'blocking', // Generate other pages on-demand
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const product = await fetch(
`https://api.example.com/products/${params!.id}`
).then(res => res.json());
return {
props: { product },
revalidate: 60, // Revalidate every 60 seconds
};
};import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
// Validate secret token
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const body = await request.json();
try {
// Revalidate by tag
if (body.tag) {
revalidateTag(body.tag);
return NextResponse.json({ revalidated: true, tag: body.tag });
}
// Revalidate by path
if (body.path) {
revalidatePath(body.path);
return NextResponse.json({ revalidated: true, path: body.path });
}
return NextResponse.json({
message: 'Missing tag or path'
}, { status: 400 });
} catch (err) {
return NextResponse.json({
message: 'Error revalidating'
}, { status: 500 });
}
}Trigger revalidation:
curl -X POST \
'http://localhost:3000/api/revalidate?secret=YOUR_SECRET' \
-H 'Content-Type: application/json' \
-d '{"tag":"products"}'When to Use ISR
ISR is perfect for e-commerce product pages, blog posts, news articles, and any content that updates occasionally but needs to be fast.
Streaming and Suspense
The App Router supports streaming with React Suspense for progressive rendering.
import { Suspense } from 'react';
// Fast component
async function UserInfo() {
const user = await fetchUser(); // Fast query
return <div>Welcome, {user.name}!</div>;
}
// Slow component
async function Analytics() {
const data = await fetchAnalytics(); // Slow query
return <AnalyticsChart data={data} />;
}
export default function Dashboard() {
return (
<div>
{/* Show user info immediately */}
<Suspense fallback={<UserSkeleton />}>
<UserInfo />
</Suspense>
{/* Stream analytics when ready */}
<Suspense fallback={<ChartSkeleton />}>
<Analytics />
</Suspense>
</div>
);
}import { Suspense } from 'react';
async function FeaturedProducts() {
const products = await fetchFeatured();
return <ProductGrid products={products} />;
}
async function RecentReviews() {
const reviews = await fetchRecentReviews();
return <ReviewsList reviews={reviews} />;
}
async function Recommendations() {
const recommendations = await fetchRecommendations();
return <ProductCarousel products={recommendations} />;
}
export default function ProductsPage() {
return (
<div>
<h1>Products</h1>
<Suspense fallback={<ProductGridSkeleton />}>
<FeaturedProducts />
</Suspense>
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<ReviewsSkeleton />}>
<RecentReviews />
</Suspense>
<Suspense fallback={<CarouselSkeleton />}>
<Recommendations />
</Suspense>
</div>
</div>
);
}// Automatically used as Suspense fallback
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
<div className="grid grid-cols-3 gap-4">
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
);
}Performance Optimization
Code Splitting
Dynamic Imports
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <Spinner />,
ssr: false, // Don't render on server
});Route-Based Splitting
Next.js automatically splits code by route
Component-Based Splitting
Split large components into separate bundles
Caching Strategies
// Force static (cached)
export const dynamic = 'force-static';
// Force dynamic (no cache)
export const dynamic = 'force-dynamic';
// Per-request caching
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache', // Default
// OR
cache: 'no-store', // Don't cache
// OR
next: { revalidate: 3600 }, // ISR
});// API route with caching headers
export async function GET() {
const data = await fetchData();
return Response.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30',
},
});
}import { unstable_cache } from 'next/cache';
// Cache database queries
const getCachedUser = unstable_cache(
async (userId: string) => {
return await db.user.findUnique({
where: { id: userId },
});
},
['user-by-id'],
{
revalidate: 3600,
tags: ['users'],
}
);
export default async function UserProfile({ userId }: Props) {
const user = await getCachedUser(userId);
return <Profile user={user} />;
}Cache Carefully
Be cautious with caching user-specific data. Always consider privacy and data freshness requirements.
SEO Optimization
Metadata API
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'My awesome application',
keywords: ['Next.js', 'React', 'JavaScript'],
authors: [{ name: 'Your Name' }],
openGraph: {
title: 'My App',
description: 'My awesome application',
url: 'https://myapp.com',
siteName: 'My App',
images: [
{
url: 'https://myapp.com/og.jpg',
width: 1200,
height: 630,
},
],
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'My App',
description: 'My awesome application',
images: ['https://myapp.com/og.jpg'],
},
};Structured Data
export default async function BlogPost({ params }: PageProps) {
const post = await getPost(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
</>
);
}Best Practices
- Choose the Right Strategy - Use SSG for static, SSR for dynamic, ISR for hybrid
- Optimize Data Fetching - Fetch in parallel, minimize waterfalls
- Cache Aggressively - But invalidate intelligently
- Use Streaming - Show content progressively
- Monitor Performance - Track TTFB, FCP, LCP
- SEO First - Leverage SSR for better search rankings
- Type Safety - Use TypeScript for better DX
- Error Handling - Graceful fallbacks for failed requests
Performance Budget
Set performance budgets: TTFB < 600ms, FCP < 1.8s, LCP < 2.5s. Monitor with tools like Lighthouse and Web Vitals.
Conclusion
Next.js provides powerful rendering strategies for every use case. The App Router with Server Components represents the future of React, offering better performance and developer experience.
Start with SSG where possible, use ISR for semi-dynamic content, and reserve SSR for truly personalized experiences.
Resources
Questions about Next.js rendering? Drop a comment below!