SSR response times typically range from 200-800ms with no caching. ISR cache hits come back in ~80ms. At 100,000 requests per day, SSR costs 10-50x more in compute than ISR serving cached pages. I learned this the hard way when I shipped a Next.js dashboard with SSR for every page and watched the server costs spike. Caching strategy isn't just a performance concern — it's an infrastructure cost decision, and the App Router gives you more granular control than ever before.
SSG (Static Site Generation) generates HTML at build time. ISR (Incremental Static Regeneration) generates HTML at first request and caches it, revalidating in the background after a time interval or on-demand. SSR (Server-Side Rendering) generates HTML on every request. RSC (React Server Components) is a rendering model that works across all three — it moves component rendering to the server, reducing client JS. Understanding which mode to use requires understanding two things: how often the data changes, and whether personalization requires request-time data.
ISR is the right default for most pages. It gives you the performance of a static site (cache hit ~80ms TTFB, served from CDN) with the ability to update content without a full rebuild. In Next.js App Router, you enable ISR by setting `revalidate` in your fetch call or route segment config. A revalidation interval of 60 seconds is appropriate for a blog listing that updates a few times per day. The cache miss on first request after the interval has passed takes ~300ms — still fast, and served stale to the user while the revalidation happens in the background (stale-while-revalidate pattern).
Time-based revalidation (revalidate: 60) is simple but imprecise — content could be stale for up to 60 seconds after a change. On-demand revalidation with `revalidatePath()` or `revalidateTag()` lets you invalidate specific cache entries immediately when content changes. For a blog, call `revalidatePath('/blog')` from a webhook when a new post publishes. For an ERP system, call `revalidateTag('orders')` when an order updates. This combines ISR's performance with near-real-time freshness.
Next.js App Router — 4 Cache Layers
────────────────────────────────────────────────────────────
Layer 1: Request Memoization
Same fetch() URL called multiple times in one render?
→ Deduped automatically, hits network once
Layer 2: Data Cache (persistent, cross-request)
fetch(url, { next: { revalidate: 60 } }) ← time-based ISR
fetch(url, { next: { tags: ['posts'] } }) ← tag-based invalidation
revalidatePath('/blog') ← on-demand invalidation
revalidateTag('posts') ← tag invalidation
Layer 3: Full Route Cache (CDN edge, HTML + RSC payload)
export const revalidate = 3600 ← route-level ISR
export const dynamic = 'force-dynamic' ← disable route cache → SSR
Layer 4: Router Cache (browser, client-side navigation)
Next.js prefetches and caches route segments
Expires: 30s (dynamic), 5min (static) — not configurable
Performance comparison:
SSG cache hit: ~10ms TTFB (CDN edge)
ISR cache hit: ~80ms TTFB (CDN edge, stale-while-revalidate)
ISR cache miss: ~300ms TTFB (origin render + cache store)
SSR (no cache): ~200-800ms TTFB (origin render every time)From my experience building data-heavy Next.js apps: use fetch caching at the component level, not the page level. Instead of setting `export const revalidate = 60` on the route, set the cache option per-fetch: `fetch(url, { next: { revalidate: 60 } })`. This gives you fine-grained control — a product price might revalidate every 5 minutes, while product descriptions can be cached for a day. The App Router's Request Memoization also deduplicates identical fetch calls within a single render, so you can call the same API from multiple server components without hitting the network multiple times.
SSR is appropriate when the response must be personalized to the specific user making the request, and that personalization cannot be done client-side after a static shell loads. Real examples: a dashboard that shows data filtered by the logged-in user's organization, a checkout page that must read the user's cart from the session, or a page whose content is gated behind a role check that must happen server-side for security reasons. For everything else — including most marketing pages, blog posts, and product listings — ISR is faster, cheaper, and sufficient.
Next.js App Router has four caching layers: Request Memoization (deduplication within one render), Data Cache (persistent, across requests, invalidated by revalidation), Full Route Cache (HTML and RSC payload, stored at the CDN edge), and Router Cache (client-side, in the browser, for navigation). Understanding all four layers is important because they interact. If you add `cache: 'no-store'` to a single fetch call, it opts that fetch out of the Data Cache, but the Full Route Cache may still serve a stale HTML page unless you also add `dynamic = 'force-dynamic'` to the route.
// app/blog/page.tsx — ISR blog listing
export const revalidate = 3600 // revalidate at most every hour
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600, tags: ['posts'] },
}).then(r => r.json())
return <PostList posts={posts} />
}
// app/api/revalidate/route.ts — webhook endpoint
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-webhook-secret')
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag('posts')
return NextResponse.json({ revalidated: true, timestamp: Date.now() })
}
// For database queries (not fetch), use React cache() or unstable_cache:
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
// React cache: memoizes within a single render only
const getCachedUser = cache(async (id: string) => {
return prisma.user.findUnique({ where: { id } })
})
// unstable_cache: persists across requests, supports tags
const getCachedPosts = unstable_cache(
async () => prisma.post.findMany({ orderBy: { createdAt: 'desc' } }),
['all-posts'], // cache key
{ tags: ['posts'], revalidate: 3600 }
)In Next.js App Router, server components can query the database directly (using Prisma, Drizzle, or a raw PostgreSQL client). These queries benefit from the Data Cache if wrapped in `cache()` from React or using fetch with the `next.revalidate` option. For non-fetch async functions (like a Prisma query), use React's `cache()` function to memoize within a render: `const getCachedPosts = cache(async () => prisma.post.findMany())`. For cross-request caching of database results, `unstable_cache` from Next.js provides a persistent cache with tag-based invalidation.
I've seen developers default to SSR for every page because 'the data needs to be fresh.' In most cases, data freshness tolerates a 30-60 second revalidation window, making ISR the right choice. SSR means every page load spawns a server-side render — at 100 concurrent users, that's 100 simultaneous server processes. At 1,000 concurrent users, your server bill multiplies accordingly. Profile your actual freshness requirement before choosing SSR, and consider whether the personalization could be moved to the client with a static shell + client-side data fetch.
ISR and SSG pages are automatically cached at the CDN edge by Vercel. For self-hosted deployments, you need to configure your reverse proxy (nginx or Caddy) to cache the responses. The Next.js server sets Cache-Control headers based on your route configuration — `s-maxage=60, stale-while-revalidate` for ISR. Your CDN must respect these headers. For Cloudflare, set a Page Rule that uses `Cache Everything` with an Edge TTL matching your revalidate interval. This way, Cloudflare serves cached pages globally and only forwards cache misses to your origin.
I use a simple decision tree for each route: Does it require user-specific data that must be server-side? → SSR. Does the data change more than once per minute? → SSR or ISR with short interval + on-demand revalidation. Does it change a few times per day? → ISR with 60-300s revalidate. Is it completely static? → SSG with on-demand revalidation for changes. For my portfolio (matthewswong.com), the blog listing is ISR with 3600s revalidate, individual blog posts are SSG regenerated on deploy, and the contact form is a static page that calls an API route client-side. Nothing uses SSR except the admin section.