SSR, SSG and ISR: How to Choose the Right Rendering Strategy
Rendering strategy selection is one of the most common sources of confusion for developers moving into Next.js App Router. In the Pages Router era, the mechanism was explicit: export getServerSideProps from a page to get SSR, export getStaticProps to get SSG or ISR. Strategy was a per-page configuration.
App Router fundamentally changed this model: rendering strategy is not configured โ it is inferred from component behavior. Understanding this inference system is the key to understanding App Router's caching and rendering architecture.
Strategy is Per-Request, Not Per-Page
In App Router, each route segment is analyzed at build time and at request time to determine the appropriate rendering strategy. The decision is driven by signals the component emits:
| Signal | Inferred strategy |
|---|---|
Calls cookies() or headers() |
Dynamic (SSR) |
Accesses searchParams prop |
Dynamic (SSR) |
fetch with cache: 'no-store' |
Dynamic (SSR) |
export const dynamic = 'force-dynamic' |
Forced dynamic |
| None of the above, data is cacheable | Static (SSG/ISR) |
export const revalidate = N |
ISR (regenerate every N seconds) |
Next.js performs static analysis of each route segment at build time. If it can determine that the output is independent of the incoming request (no user-specific data, no real-time requirements), it statically generates the page. Otherwise it renders dynamically at request time.
The implication is significant: simply calling cookies() anywhere in a route segment's component tree pushes the entire segment into dynamic rendering, even if you only read a theme preference cookie that doesn't actually affect the rendered content. Being intentional about which dynamic APIs you call โ and where โ is critical for performance.
force-dynamic and force-static: Explicit Overrides
Sometimes you need to override the automatic inference:
// app/dashboard/page.tsx
// Force re-render on every request, even if Next.js thinks it can cache
export const dynamic = 'force-dynamic'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
export default async function DashboardPage() {
const cookieStore = await cookies()
const userId = cookieStore.get('user_id')?.value
if (!userId) {
redirect('/login')
}
const stats = await db.userStats.findUnique({ where: { userId } })
return <Dashboard stats={stats} />
}
// app/about/page.tsx
// Force static generation, even if some dynamic signals appear in the tree
export const dynamic = 'force-static'
export default function AboutPage() {
// With force-static, even calling cookies() here won't trigger dynamic rendering
// (cookies() returns an empty store instead)
return <div>About us (purely static)</div>
}
The dynamic export accepts four values:
'auto'(default): Automatic inference from signals'force-dynamic': Always render at request time (SSR)'force-static': Always statically generate; dynamic APIs return empty/default values'error': Throw a build error if the component attempts dynamic rendering โ useful as a guard to ensure a page remains static in CI
ISR: Incremental Static Regeneration
ISR is the evolution of SSG: pages are statically generated at build time but automatically regenerate after a specified time interval. In App Router, this is controlled with the revalidate export:
// app/blog/[slug]/page.tsx
// Revalidate cached output every 3600 seconds (1 hour)
export const revalidate = 3600
import { db } from '@/lib/db'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ slug: string }>
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await db.post.findUnique({
where: { slug },
include: { author: true, tags: true },
})
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
ISR operates on the Stale While Revalidate semantic:
- On first request (no cache): render dynamically, cache the result
- Subsequent requests within the
revalidatewindow: serve cached output โ millisecond response times - After the
revalidatewindow expires: the next request triggers background regeneration - While regeneration runs: continue serving the stale cache; once complete, serve the new output
From the user's perspective, ISR almost always returns cached content (performance equivalent to pure SSG), but the content refreshes periodically rather than going stale permanently.
generateStaticParams: Pre-Building Dynamic Routes
For routes with dynamic segments like [slug] or [id], Next.js needs to know which paths to generate at build time. generateStaticParams provides this information:
// app/products/[id]/page.tsx
import { db } from '@/lib/db'
import { notFound } from 'next/navigation'
// Executes at build time โ tells Next.js which paths to pre-render
export async function generateStaticParams() {
const products = await db.product.findMany({
select: { id: true },
where: { active: true },
})
return products.map(product => ({
id: product.id,
}))
}
// Revalidation period for each pre-generated path
export const revalidate = 86400 // 24 hours
interface Props {
params: Promise<{ id: string }>
}
export default async function ProductPage({ params }: Props) {
const { id } = await params
const product = await db.product.findUnique({ where: { id } })
if (!product) notFound()
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
<p>In stock: {product.stock}</p>
</div>
)
}
For paths not covered by generateStaticParams (such as newly added products), Next.js defaults to rendering them dynamically on first request and then caching the result. You can control this with dynamicParams:
// Return 404 for paths not pre-generated, rather than dynamic rendering
export const dynamicParams = false
Setting dynamicParams = false is appropriate when you want a completely closed set of pages โ for example, a documentation site where all pages are known at build time and new pages should be explicitly deployed, not auto-generated on demand.
On-Demand Revalidation: revalidatePath and revalidateTag
ISR's time-based revalidation is sometimes too slow. A product price changes โ you don't want to wait up to an hour for the update to propagate. On-demand revalidation allows immediate cache invalidation when data changes:
// app/api/webhooks/cms/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
import { verifySignature } from '@/lib/webhooks'
export async function POST(request: NextRequest) {
// Security: verify the webhook signature
const rawBody = await request.text()
const signature = request.headers.get('x-webhook-signature')
if (!verifySignature(rawBody, signature)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = JSON.parse(rawBody)
const { type, slug } = body
if (type === 'post.updated' || type === 'post.published') {
// Invalidate the cache for a specific path
revalidatePath(`/blog/${slug}`)
// Also invalidate the listing page
revalidatePath('/blog')
}
if (type === 'product.updated') {
// Invalidate all caches tagged with 'products'
revalidateTag('products')
// Invalidate the specific product too
revalidateTag(`product-${body.productId}`)
}
return NextResponse.json({ revalidated: true, timestamp: Date.now() })
}
For revalidateTag to work, fetch calls must declare their tags:
// lib/api.ts
export async function getProducts() {
const response = await fetch('https://api.example.com/products', {
next: {
tags: ['products'], // Tag this fetch's cache entry
revalidate: 3600, // Also set a time-based fallback
},
})
return response.json()
}
export async function getProduct(id: string) {
const response = await fetch(`https://api.example.com/products/${id}`, {
next: {
tags: ['products', `product-${id}`], // Multiple tags are fine
},
})
return response.json()
}
When the CMS triggers a webhook and the handler calls revalidateTag('products'), every cached fetch response tagged with 'products' is invalidated. Pages that used those fetches will regenerate on their next request. The combination of ISR and on-demand revalidation gives you cache performance with content accuracy controlled by your data source rather than a clock.
Rendering Strategy Decision Framework
Different business domains have different requirements. Here is a practical framework for the most common scenarios:
E-commerce
// Product detail pages โ ISR, product data changes infrequently
// app/products/[id]/page.tsx
export const revalidate = 300 // 5 minutes; webhook for instant updates
// Product inventory/pricing โ SSR, real-time accuracy required
// app/api/products/[id]/stock/route.ts
export const dynamic = 'force-dynamic'
// Shopping cart โ SSR, user-specific, uncacheable
// app/cart/page.tsx
export const dynamic = 'force-dynamic'
// Reads cart from session cookie
// Marketing homepage โ ISR, marketing team updates frequently
// app/(marketing)/page.tsx
export const revalidate = 60 // 1 minute; plus CMS webhook for instant refresh
// Category navigation โ Near-static, changes only with catalog restructuring
// app/(marketing)/categories/page.tsx
export const revalidate = false // Never auto-expire; webhook-only revalidation
Blog / Content Site
// Article pages โ SSG + on-demand ISR
// app/posts/[slug]/page.tsx
export const revalidate = false // No automatic expiry
export async function generateStaticParams() {
// Pre-generate all published posts at build time
const posts = await getPosts({ status: 'published' })
return posts.map(p => ({ slug: p.slug }))
}
// When CMS publishes or updates a post, webhook calls revalidatePath
// Article listing โ ISR, new posts appear within 1 hour maximum
// app/posts/page.tsx
export const revalidate = 3600
// Author profiles โ Near-static, rarely change
// app/authors/[id]/page.tsx
export const revalidate = 86400 // 24 hours
SaaS Application
// Dashboard โ SSR, real-time user data
export const dynamic = 'force-dynamic'
// Settings page โ SSR, reads user configuration
export const dynamic = 'force-dynamic'
// Public documentation โ SSG, changes only with product releases
export const revalidate = false
// API usage statistics โ ISR if 5-minute lag is acceptable
export const revalidate = 300
// Or SSR if real-time precision is required
// export const dynamic = 'force-dynamic'
Fine-Grained Control: fetch-Level Cache Configuration
Beyond route segment level configuration, Next.js allows per-fetch cache control:
// app/page.tsx
export default async function HomePage() {
// No caching โ equivalent to SSR behavior for this specific data
const liveData = await fetch('https://api.example.com/live', {
cache: 'no-store',
})
// ISR-style caching โ revalidate after 60 seconds
const semiStaticData = await fetch('https://api.example.com/config', {
next: { revalidate: 60 },
})
// Permanent cache โ equivalent to SSG behavior, revalidate only via webhook
const staticData = await fetch('https://api.example.com/constants', {
cache: 'force-cache',
})
// ...
}
Important: if any fetch in a route uses cache: 'no-store', Next.js infers the entire route as dynamic โ unless the route has export const dynamic = 'force-static' overriding it. Understanding this inference chain prevents unexpected dynamic behavior from accidentally propagating through your route.
Route Segment Configuration Reference
For quick reference, here are all the route segment configuration exports and their effects:
// Rendering strategy
export const dynamic = 'auto' | 'force-dynamic' | 'error' | 'force-static'
// Revalidation interval (seconds); false = never auto-revalidate
export const revalidate = false | 0 | number
// Whether to allow dynamic params not listed in generateStaticParams
export const dynamicParams = true | false
// Runtime environment
export const runtime = 'nodejs' | 'edge'
// Fetch cache behavior
export const fetchCache = 'auto' | 'default-cache' | 'only-cache'
| 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'
Summary
App Router's rendering strategy system operates on "inferred behavior" rather than "explicit configuration." Dynamic APIs like cookies() and headers() push a route into SSR; absence of those signals, combined with cacheable data access, results in static generation. revalidate adds ISR semantics with time-based freshness. On-demand revalidation via revalidatePath and revalidateTag upgrades ISR from clock-driven to event-driven โ the right model for most content sites. The decision framework is straightforward: use SSG for content that never changes; ISR for content that changes periodically; SSR for real-time or user-specific content. Apply the strategy at the finest possible granularity, using per-fetch configuration to mix strategies within a single route.