Next.js ISR Guide 2026: Incremental Static Regeneration with App Router
Konrad Bachowski
Tech lead, HeyNeuron
What Is Next.js ISR — and Why It Matters in 2026
Next.js Incremental Static Regeneration (ISR) lets you serve pre-built static HTML — with sub-50ms time-to-first-byte on cache hits — while automatically refreshing pages in the background when data changes. You get static-site speed without the full rebuild penalty every time a blog post, product price, or CMS entry updates.
According to a 2026 web performance study by webvitals.tools, ISR cached pages match pure SSG in TTFB, while SSR-level latency only appears on the first post-revalidation request. A ResearchGate-published benchmark on Next.js cloud deployments found SSG and ISR cache hits deliver 60–80% faster TTFB and First Contentful Paint compared to server-rendered requests — a measurable ranking and conversion advantage given that Google's CWV algorithm factors in real-user TTFB data.
In the App Router (introduced in Next.js 13, stable in Next.js 14–15), ISR works differently from the Pages Router. getStaticProps revalidate is gone. In its place you get:
- Segment-level
revalidate— set as a route config export for time-based revalidation revalidatePath()— on-demand, path-scoped cache bustingrevalidateTag()— on-demand, data-tag-scoped cache busting (the precise alternative)unstable_cache()— wraps arbitrary database/API calls with tag support
This guide covers all four patterns, plus CMS webhook integration, ORM caching, self-hosting with Redis, cost comparison, and an honest "when NOT to use ISR" section.
ISR vs SSR vs SSG: Choosing the Right Strategy
One sentence of context before the table: the right strategy depends on how often your data changes, how many pages you have, and your tolerance for stale content.
| Strategy | TTFB (cached) | Content freshness | Build time | Best for |
|---|---|---|---|---|
| SSG | <50ms | Rebuild required | Slow (all pages) | Marketing sites, docs, content that rarely changes |
| ISR | <50ms (cache hit) | Configurable (seconds → minutes) | Fast (per-page background) | Blogs, product pages, news, dashboards |
| SSR | 200–800ms | Always fresh | No build step | Real-time data: stock prices, live scores, auth-gated content |
| PPR (Partial Prerendering) | <50ms shell | Deferred dynamic slots | Fast | App-like pages with mixed static + dynamic content |
ISR is the default choice for any page that changes on a schedule or in response to CMS edits. Reserve SSR for pages where stale data creates real business risk.
For pages with more than 10,000 paths (large e-commerce catalogs, user-generated content), ISR with dynamicParams = true regenerates on first access, avoiding build-time timeouts entirely.
Time-Based Revalidation in the App Router
Time-based ISR tells Next.js: "keep this page cached, but regenerate it in the background after N seconds." The configuration lives in the route segment, not in the fetch call.
Segment-level revalidation (recommended for entire routes):
// app/products/[id]/page.tsx
export const revalidate = 60 // Regenerate at most once per 60 seconds
export default async function ProductPage({ params }) {
const product = await fetch(`https://api.store.com/products/${params.id}`)
.then(r => r.json())
return <ProductDetail product={product} />
}
Fetch-level revalidation (overrides segment config for a specific call):
const data = await fetch('https://api.cms.com/posts', {
next: { revalidate: 300 } // 5 minutes
})
Key behavior to know: Next.js uses a stale-while-revalidate model. When a cached page is older than revalidate seconds and a new request arrives, Next.js serves the stale page immediately and triggers a background regeneration. The next request gets the fresh page. This means your revalidate value is a minimum time between regenerations, not an exact TTL.
Opting out of caching for a single fetch inside a cached route:
const livePrice = await fetch('https://api.prices.com/live', {
cache: 'no-store' // This call is always fresh; rest of page is still ISR
})
This granular control is one of the biggest advantages of the App Router ISR model over the Pages Router.
On-Demand Revalidation: revalidatePath vs revalidateTag
On-demand revalidation lets you purge specific cached pages or data the moment content changes — triggered from a Server Action, API route handler, or webhook endpoint.
One sentence of context: revalidatePath is "brute force" (rebuilds an entire route), while revalidateTag is "precision" (invalidates only the data with a specific tag, regardless of which pages reference it).
revalidatePath |
revalidateTag |
|
|---|---|---|
| Scope | One path or all paths matching a pattern | All cache entries with a given tag |
| Use case | After updating a specific page's content | After updating a shared data source (e.g., author profile used on 50 articles) |
| Granularity | Route-level | Data-level |
| Best for | CMS page updates, form submissions | Shared components, global nav, product pricing |
// Server Action — revalidate a single product page
'use server'
import { revalidatePath } from 'next/cache'
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data })
revalidatePath(`/products/${id}`)
}
// Tag-based — revalidate all pages that reference "homepage-hero"
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const body = await request.json()
revalidateTag('homepage-hero') // Clears all cached fetches tagged with this key
return Response.json({ revalidated: true })
}
To use revalidateTag, you must add tags to your fetch calls:
const hero = await fetch('https://api.cms.com/hero-section', {
next: { tags: ['homepage-hero', 'global-content'] }
})
revalidatePath gotcha: Calling revalidatePath('/') with the default 'page' type clears only the / page cache. Calling it with type: 'layout' clears all pages that share that layout — useful after global nav or footer updates, but potentially expensive on large sites.
CMS Webhook Integration: Automated On-Demand Revalidation
The most production-relevant ISR pattern is connecting your headless CMS (Contentful, Sanity, Strapi, Storyblok) to a Next.js webhook endpoint so pages revalidate the moment an editor publishes.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
const WEBHOOK_SECRET = process.env.REVALIDATION_SECRET
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-webhook-signature')
// Validate webhook secret to prevent unauthorized cache busting
if (signature !== WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { type, slug, tags } = body
if (type === 'page' && slug) {
revalidatePath(`/blog/${slug}`)
}
if (tags && Array.isArray(tags)) {
tags.forEach((tag: string) => revalidateTag(tag))
}
return NextResponse.json({ revalidated: true, timestamp: Date.now() })
}
Webhook configuration checklist:
- [ ] Generate and store a REVALIDATION_SECRET environment variable
- [ ] Set the secret in your CMS webhook config (header: x-webhook-signature)
- [ ] Test with curl -X POST before going live
- [ ] Add monitoring — log revalidation events to catch webhook failures
- [ ] Handle CMS retries — return 200 for any valid payload, even if already revalidated
Most headless CMS platforms support custom webhook headers. Sanity uses sanity-webhook-signature with HMAC-SHA256 for stronger verification — consider implementing that pattern for production.
Database Caching with unstable_cache and ORMs
unstable_cache wraps any async function (Prisma query, Drizzle query, raw SQL) in Next.js's data cache with tag support. Despite the "unstable" prefix, it's the recommended approach in Next.js 15 for caching database calls outside of fetch.
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'
export const getCachedProduct = unstable_cache(
async (id: string) => {
return prisma.product.findUnique({ where: { id } })
},
['product'], // Cache key prefix
{
revalidate: 3600, // 1 hour time-based fallback
tags: ['products', `product-${id}`] // Tag-based invalidation support
}
)
// Usage in a Server Component:
const product = await getCachedProduct(params.id)
Important: The id variable in the tags array above must be resolved at call time, not at definition time. Pass it as a parameter:
export const getCachedProduct = unstable_cache(
async (id: string) => prisma.product.findUnique({ where: { id } }),
['product-detail'],
{ revalidate: 3600, tags: ['products'] }
)
Then invalidate all product pages at once with revalidateTag('products') after a bulk import.
According to a 2026 Next.js performance analysis by DEV Community contributor Tamizuddin, proper caching with unstable_cache can eliminate up to 80% of database round-trips on data-heavy Next.js applications without any architectural changes to the database layer.
Production Checklist: ISR in 10 Steps
Before shipping ISR to production, verify each item:
- [ ] Segment config set —
export const revalidate = Norrevalidate: 0for no-cache explicitly declared in every dynamic route - [ ] No conflicting cache directives — conflicting fetch-level
cache: 'no-store'and segmentrevalidatevalues are resolved (no-store wins per route) - [ ] Tags are consistent — same tag strings used in
fetch next.tags,unstable_cache tags,revalidatePath, andrevalidateTagcalls - [ ] Webhook endpoint secured —
REVALIDATION_SECRETset and validated on every POST - [ ]
x-nextjs-cachemonitored — log HIT/STALE/MISS headers to confirm cache is working in production - [ ]
generateStaticParamscovers top paths — pre-generate high-traffic routes at build time; let long-tail routes regenerate on first access - [ ]
dynamicParams = trueconfirmed — so unknown paths don't return 404 on first request - [ ] Error handling tested — stale page served if regeneration fails; confirm this behavior with a mock API timeout
- [ ] Multi-instance cache handler — if running 2+ server instances (Docker, Kubernetes), Redis handler configured (see below)
- [ ] Build log reviewed — confirm ISR pages show "○ (Static, ISR)" indicator in Next.js build output, not "(Dynamic)"
Self-Hosting ISR with Redis: Multi-Instance Setup
The default Next.js file-system ISR cache is node-local — each server instance maintains its own cache. On a 3-node deployment, a revalidation triggered on node 1 doesn't propagate to nodes 2 or 3 until they receive their own cache-miss request.
The solution is a shared external cache handler. Next.js 15 supports custom cacheHandler in next.config.ts:
// next.config.ts
const nextConfig = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // Disable in-memory cache when using Redis
}
A minimal Redis cache handler (cache-handler.js):
const { createClient } = require('redis')
const client = createClient({ url: process.env.REDIS_URL })
client.connect()
module.exports = class CacheHandler {
async get(key) {
const data = await client.get(key)
return data ? JSON.parse(data) : null
}
async set(key, data, ctx) {
const ttl = ctx.revalidate ?? 3600
await client.setEx(key, ttl, JSON.stringify(data))
}
async revalidateTag(tag) {
// Requires a tag-to-keys mapping strategy (e.g., Redis Sets)
const keys = await client.sMembers(`tag:${tag}`)
if (keys.length > 0) await client.del(keys)
}
}
Cost comparison: Vercel vs self-hosted ISR
| Hosting | ISR cost model | Approx. monthly cost | Redis required? |
|---|---|---|---|
| Vercel Pro | Per-region cache + function invocations | $20–$200+/mo depending on traffic | No (built-in) |
| Self-hosted (single node) | VPS fixed cost | $5–$30/mo (Hetzner/DigitalOcean) | No (file-system) |
| Self-hosted (multi-node) | VPS + Redis instance | $15–$60/mo | Yes ($5–$15/mo Redis) |
| Railway / Render | Usage-based + Redis addon | $10–$50/mo | Optional |
For most Next.js projects under 100K monthly visitors, single-node self-hosting with file-system ISR cache is sufficient. The Redis setup becomes necessary when you run load-balanced deployments or need sub-second cache propagation across regions.
Debugging ISR in Production
Check the x-nextjs-cache response header to diagnose caching behavior:
curl -I https://yoursite.com/blog/my-post | grep x-nextjs-cache
# Expected outputs:
# HIT — Served from cache, no regeneration needed
# STALE — Served stale, background regeneration triggered
# MISS — Cache empty, page rendered fresh (first request or after invalidation)
# REVALIDATED — Fresh page generated and now cached
Common issues:
- Pages always MISS: Check for cache: 'no-store' in nested fetch calls, or cookies()/headers() usage that opts the route into dynamic rendering
- Pages show HIT after revalidatePath: The webhook may not be reaching your server — check network rules, tunnel setup (ngrok for local dev), and response status
- Multi-instance pages serving stale after revalidation: You need the Redis cache handler
When NOT to Use ISR
ISR is not always the right choice. Skip it in these four scenarios:
-
Real-time data requirements — live sports scores, stock prices, IoT dashboards where stale data is a functional failure. Use SSR with
cache: 'no-store'or WebSockets. -
Authentication-gated pages — user-specific content (account pages, order history, personalized dashboards) cannot be cached at the CDN/HTTP layer. Use SSR or client-side fetching after auth.
-
Very low traffic, few pages — for a 5-page marketing site with weekly content updates, a full rebuild triggered via CI/CD on every commit is simpler and has no ISR complexity overhead.
-
Extremely high path counts with unpredictable access patterns — catalogs with 5M+ SKUs where 90% of paths are never visited waste regeneration capacity. Use SSR with aggressive HTTP caching (
Cache-Control: s-maxage) and a CDN in front instead.
Frequently Asked Questions
What is the difference between revalidatePath and revalidateTag in Next.js?
revalidatePath clears the cache for a specific URL path (e.g., /blog/my-post). revalidateTag clears all cached entries — across any number of pages — that were tagged with a specific string. Use revalidateTag when one data source feeds many pages; use revalidatePath for single-page content updates.
How does ISR work in Next.js App Router vs Pages Router?
In the Pages Router, ISR was configured via getStaticProps with a revalidate property. In the App Router, you use the revalidate route segment config, revalidatePath(), and revalidateTag() Server Actions or Route Handlers. The underlying stale-while-revalidate behavior is the same.
Can I use ISR with a database (Prisma, Drizzle) in Next.js?
Yes. Wrap your database queries with unstable_cache() from next/cache, adding tags for on-demand invalidation. Call revalidateTag('your-tag') from a Server Action or webhook when data changes in the database.
How do I know if ISR is working in production?
Check the x-nextjs-cache response header: HIT means the page was served from cache, STALE means stale page served while regenerating in the background, MISS means cache was empty. You can also check the Next.js build output — ISR pages show ○ (Static, ISR).
What happens if a background ISR regeneration fails?
Next.js serves the last successfully generated stale page rather than showing an error. This is by design — ISR degrades gracefully. The failed regeneration is retried on the next request.
How do I use ISR with multiple server instances?
By default, each server instance maintains its own file-system ISR cache. For consistent behavior across multiple instances, configure a custom cacheHandler that uses a shared cache like Redis. Set this in next.config.ts with cacheHandler: require.resolve('./cache-handler.js').
What is unstable_cache in Next.js and should I use it?
unstable_cache wraps non-fetch async functions (database queries, third-party SDK calls) in Next.js's data cache with the same tag-based invalidation as fetch next.tags. Despite the "unstable" prefix, it's the recommended approach in Next.js 15 for caching ORM queries. The name reflects that the API may change in future minor versions, not that it's unsafe.
Does ISR work with Next.js middleware?
ISR and middleware interact carefully. Middleware runs on every request before the cache is consulted for ISR, which can cause unexpected ISR misses. Avoid calling cookies() or headers() in layouts/pages that you intend to cache — these APIs opt routes into dynamic rendering, bypassing ISR entirely.
The Right ISR Strategy for Your Project
Next.js ISR with the App Router is the production-standard data fetching strategy for any page that changes on a schedule or in response to CMS events. The key decisions:
- Time-based ISR (
export const revalidate = N) for pages with predictable update frequency - Tag-based on-demand (
revalidateTag) for shared data sources feeding many pages - Path-based on-demand (
revalidatePath) for single-page CMS webhook triggers unstable_cachefor any database or SDK call that isn't afetch
If you're building or migrating a Next.js application and need help designing the right ISR and caching architecture, get in touch with our Next.js development team — we've shipped production ISR setups from simple blogs to multi-tenant SaaS platforms.
For related Next.js guides, see our Next.js App Router best practices, Next.js authentication guide, Next.js Core Web Vitals optimization, and Next.js self-hosting guide.
Stay up to date with AI and automation
Subscribe to our newsletter to receive specific tips and tools once a week. Join over 2,000 subscribers.