Free quote
Back to Blog
Article
October 5, 202615 min read

Next.js ISR Guide 2026: Incremental Static Regeneration with App Router

KB

Konrad Bachowski

Tech lead, HeyNeuron

Next.js ISR Guide 2026: Incremental Static Regeneration with App Router

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 busting
  • revalidateTag() — 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 = N or revalidate: 0 for no-cache explicitly declared in every dynamic route
  • [ ] No conflicting cache directives — conflicting fetch-level cache: 'no-store' and segment revalidate values are resolved (no-store wins per route)
  • [ ] Tags are consistent — same tag strings used in fetch next.tags, unstable_cache tags, revalidatePath, and revalidateTag calls
  • [ ] Webhook endpoint secured — REVALIDATION_SECRET set and validated on every POST
  • [ ] x-nextjs-cache monitored — log HIT/STALE/MISS headers to confirm cache is working in production
  • [ ] generateStaticParams covers top paths — pre-generate high-traffic routes at build time; let long-tail routes regenerate on first access
  • [ ] dynamicParams = true confirmed — 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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_cache for any database or SDK call that isn't a fetch

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.

Your data is safe. Zero spam.