Master Next.js rendering strategies. Learn how CSR, SSR, ISR, and SSG work under the hood, when to use each approach, and how to implement them for optimal performance and SEO.

Next.js is a React framework that lets you choose how and when to render your pages. This flexibility is powerful but confusing. Should you render on the client? On the server? At build time? On-demand?
The answer depends on your use case. A marketing homepage has different requirements than a real-time dashboard. A product catalog differs from a user profile page.
This guide explains how each rendering strategy works, why it matters, and when to use it. We'll focus on App Router, the modern Next.js approach.
When you visit a website, your browser makes an HTTP request. The server responds with HTML. Your browser parses the HTML, downloads CSS and JavaScript, executes the JavaScript, and renders the page.
The question is: where does the HTML come from?
Client-side rendering (CSR): The server sends an empty HTML shell. JavaScript runs in the browser, fetches data, and renders the page.
Server-side rendering (SSR): The server generates the complete HTML with data already included. The browser receives a fully-rendered page.
Static site generation (SSG): HTML is generated once at build time. The same HTML is served to every user.
Incremental static regeneration (ISR): HTML is generated at build time, but can be regenerated on-demand or on a schedule.
Each approach has trade-offs. CSR is fast to deploy but slow for users. SSR is slower to deploy but fast for users. SSG is fastest but requires rebuilding for content changes. ISR balances both.
In Next.js, hydration is the process where JavaScript takes over a server-rendered page. The server sends HTML. The browser renders it. Then JavaScript runs and makes the page interactive.
This is important. A server-rendered page without JavaScript is static. Hydration makes it interactive.
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
)
}When this component is server-rendered, the server sends HTML with Count: 0. The button is there but doesn't work. When JavaScript loads, React hydrates the component, attaches event listeners, and the button becomes interactive.
Client-side rendering means the browser does all the work. The server sends minimal HTML. JavaScript runs in the browser, fetches data, and renders the page.
'use client'
import { useEffect, useState } from 'react'
export default function Products() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data)
setLoading(false)
})
}, [])
if (loading) return <div>Loading...</div>
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
)
}Here's what happens:
/api/productsCSR is ideal for:
Server-side rendering means the server generates the complete HTML for each request. The browser receives a fully-rendered page.
import { Suspense } from 'react'
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store'
})
return res.json()
}
async function ProductList() {
const products = await getProducts()
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
)
}
export default function Products() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProductList />
</Suspense>
)
}Here's what happens:
SSR is ideal for:
Static site generation means HTML is generated once at build time. The same HTML is served to every user.
async function getProducts() {
const res = await fetch('https://api.example.com/products')
return res.json()
}
export async function generateStaticParams() {
const products = await getProducts()
return products.map(product => ({
id: product.id.toString()
}))
}
export default function Product({ params }) {
// This runs at build time, not request time
return <div>Product {params.id}</div>
}Here's what happens:
generateStaticParams()SSG is ideal for:
ISR combines the best of SSG and SSR. HTML is generated at build time, but can be regenerated on-demand or on a schedule.
async function getProduct(id) {
const res = await fetch(`https://api.example.com/products/${id}`)
return res.json()
}
export async function generateStaticParams() {
const products = await fetch('https://api.example.com/products').then(r => r.json())
return products.slice(0, 100).map(p => ({ id: p.id.toString() }))
}
export const revalidate = 60 // Regenerate every 60 seconds
export default function Product({ params }) {
const product = getProduct(params.id)
return <div>{product.name}</div>
}Here's what happens:
You can also regenerate HTML on-demand using the revalidateTag() function:
async function getProduct(id) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: ['products'] }
})
return res.json()
}
export const revalidate = false // Never auto-regenerate
export default function Product({ params }) {
const product = getProduct(params.id)
return <div>{product.name}</div>
}Then, when data changes, call the revalidation API:
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const secret = request.headers.get('x-api-secret')
if (secret !== process.env.REVALIDATION_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
revalidateTag('products')
return new Response('Revalidated', { status: 200 })
}ISR is ideal for:
Streaming is a technique where the server sends HTML in chunks instead of all at once. The browser starts rendering before the entire page is ready.
import { Suspense } from 'react'
async function SlowComponent() {
await new Promise(resolve => setTimeout(resolve, 3000))
return <div>This took 3 seconds to load</div>
}
export default function Page() {
return (
<div>
<h1>Fast content</h1>
<Suspense fallback={<div>Loading...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}Here's what happens:
The user sees content faster, even though the total time is the same.
Streaming is ideal for:
Is the content the same for all users?
Does content change frequently?
Is SEO important?
Do you need real-time data?
Is the page behind authentication?
Blog homepage: SSG. Content is static. Regenerate daily or on-demand when new posts are published.
Blog post: SSG. Content is static. Regenerate when the post is updated.
Product page: ISR. Content is mostly static but prices and inventory change. Regenerate every hour or on-demand.
Shopping cart: CSR. Highly interactive. User-specific. No server-side rendering needed.
User dashboard: CSR or SSR. User-specific data. SSR if SEO matters, CSR if not.
Search results: SSR. Dynamic content based on query. Can't pre-render all possibilities.
Real-time stock ticker: CSR. Data changes constantly. Server-side rendering is wasteful.
'use client'
import { useEffect, useState } from 'react'
export default function Page() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
setError(err)
setLoading(false)
})
}, [])
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <div>{data.content}</div>
}async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}async function getData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
export const revalidate = false // Never revalidate
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}async function getData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
export const revalidate = 3600 // Revalidate every hour
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store' // Always fetch fresh
})
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}If the data doesn't change, use SSG:
async function getData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
export const revalidate = false
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}'use client'
import { useEffect, useState } from 'react'
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData)
}, [])
return <div>{data.content}</div> // Crashes if data is null
}Always handle loading and error states:
'use client'
import { useEffect, useState } from 'react'
export default function Page() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => {
setData(data)
setLoading(false)
})
}, [])
if (loading) return <div>Loading...</div>
if (!data) return <div>No data</div>
return <div>{data.content}</div>
}async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store' // Fetches fresh every time
})
return res.json()
}
export const revalidate = 3600 // Revalidate every hour
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}The cache: 'no-store' overrides revalidate. Use cache: 'force-cache' for ISR:
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache' // Cache the response
})
return res.json()
}
export const revalidate = 3600
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}'use client'
import { useEffect } from 'react'
async function getData() {
'use server'
const res = await fetch('https://api.example.com/data')
return res.json()
}
export default function Page() {
useEffect(() => {
getData() // This doesn't work as expected
}, [])
return <div>Page</div>
}Use server components for data fetching:
async function getData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>{data.content}</div>
}For blogs, documentation, and product catalogs, ISR is the sweet spot. Generate pages at build time, regenerate on-demand or on a schedule.
export const revalidate = 86400 // Regenerate daily
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map(post => ({ slug: post.slug }))
}
export default async function Post({ params }) {
const post = await getPost(params.slug)
return <article>{post.content}</article>
}A page can use multiple strategies. The homepage might be SSG, but the sidebar might be CSR.
import { Suspense } from 'react'
// Server component - SSG
async function Header() {
const data = await fetch('https://api.example.com/header')
return <header>{data.title}</header>
}
// Client component - CSR
'use client'
function Sidebar() {
const [items, setItems] = useState([])
useEffect(() => {
fetch('/api/sidebar').then(r => r.json()).then(setItems)
}, [])
return <aside>{items.map(item => <div>{item}</div>)}</aside>
}
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar />
</Suspense>
</div>
)
}Different rendering strategies affect performance differently. Monitor:
SSG and ISR typically have the best LCP. CSR might have worse LCP but better FID if the page is highly interactive.
When content changes, trigger revalidation immediately instead of waiting for the schedule:
import { revalidatePath } from 'next/cache'
export async function POST(request) {
const secret = request.headers.get('x-webhook-secret')
if (secret !== process.env.WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
const { postId } = await request.json()
revalidatePath(`/blog/${postId}`)
return new Response('Revalidated', { status: 200 })
}next/image with priority for above-the-fold images.next/image with priority for critical images.next/image with lazy loading for non-critical images.import Image from 'next/image'
export default function Page() {
return (
<div>
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Load immediately
/>
<Image
src="/thumbnail.jpg"
alt="Thumbnail"
width={300}
height={300}
// Lazy load by default
/>
</div>
)
}Next.js rendering strategies are tools. Each has strengths and weaknesses. The best choice depends on your content, traffic, and requirements.
SSG is fastest and cheapest. Use it for static content.
ISR balances freshness and performance. Use it for mostly-static content that changes occasionally.
SSR provides real-time data and personalization. Use it for dynamic content that needs SEO.
CSR is simple and interactive. Use it for highly interactive pages and personalized content.
Start with SSG or ISR. Move to SSR or CSR only when necessary. Monitor performance and adjust as needed. Your users will thank you.