Musa Yılmaz
·6 min read

How Next.js ISR and On-Demand Revalidation Work

nextjsisrperformancecms

Static generation is fast: a page is built once and served from a CDN in milliseconds. It has one problem — when the content changes, the page doesn't. It stays stale until you redeploy.

Server-side rendering fixes that but runs the server on every request, which is both slower and more expensive.

ISR (Incremental Static Regeneration) sits between them: the page stays static, but it gets regenerated in the background on a schedule or on demand.

ApproachWhen the page is builtSpeedContent freshness
SSGOnce, at build timeFastestNever changes without a redeploy
SSROn every requestSlowest, costs server timeAlways current
ISRAt build time + regenerated on a schedule or triggerSame as SSGUpdates within minutes or seconds

Time-based revalidation

The simplest form is giving a page a revalidation window:

// app/blog/page.tsx
export const revalidate = 3600; // seconds — one hour

This means: serve the page statically, and once an hour has passed, let the next request trigger a background rebuild. That visitor still gets the old page (they are never blocked); subsequent visitors get the fresh one.

This behaviour is known as stale-while-revalidate, and it has an important consequence: no user ever waits for a rebuild. Pages always arrive instantly; they're occasionally one cycle old.

On Vercel this needs no extra configuration — it's the framework default; I cover the platform's other free-tier limits in Vercel Free Tier Limits.

Pick the window based on how often content actually changes:

Content typeReasonable revalidate
Blog index3600 (1 hour)
Product price300 (5 minutes)
Campaign page60 (1 minute)
About page86400 (1 day)

Scheduled publishing: a practical use

A less obvious benefit of ISR is scheduling content ahead of time. Filter out entries whose date is still in the future:

function isVisible(post: Post) {
  const today = new Date().toISOString().slice(0, 10);
  if (post.published === false) return false;
  if (post.date > today) return false; // publication day hasn't arrived
  return true;
}

Because revalidate is set, the page regenerates periodically; during that regeneration new Date() returns the new day and that day's post appears on its own. No cron job, no automated commit, no external service.

There's one detail for detail pages. generateStaticParams only builds paths that are visible at build time, so tomorrow's post isn't in the list. That requires dynamicParams:

export const revalidate = 3600;
export const dynamicParams = true; // render unknown slugs on first request

dynamicParams already defaults to true, but stating it makes the intent explicit — if someone sets it to false, scheduled posts start returning 404.

On-demand revalidation: instant updates

Time-based revalidation is enough most of the time, but sometimes you need "update now." A client changing a price in an admin panel shouldn't wait an hour.

Next.js provides revalidatePath and revalidateTag for this. Wrap them in an API route so they can be triggered externally:

// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const secret = request.headers.get("authorization")?.replace("Bearer ", "");
 
  if (!secret || secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
  }
 
  const { path } = await request.json();
 
  if (typeof path !== "string" || !path.startsWith("/")) {
    return NextResponse.json({ message: "Invalid path" }, { status: 400 });
  }
 
  revalidatePath(path);
  return NextResponse.json({ revalidated: true, path });
}

Three security points here:

  1. The endpoint must be protected. Left open, anyone can trigger it and hammer your app into a regeneration loop.
  2. Send the secret in a header, not a query string. Query strings end up in server logs, browser history, and referrer headers.
  3. Validate the incoming path. Using a client-supplied value unchecked lets callers revalidate arbitrary routes.

Triggering from the backend

In a setup that runs Next.js and Django on the same origin (see Running Next.js + Django on a Single Origin), this trigger usually lives on the Django side. A model signal can call this endpoint whenever content is saved:

# signals.py
import requests
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
 
@receiver(post_save, sender=Route)
def revalidate_route_page(sender, instance, **kwargs):
    try:
        requests.post(
            f"{settings.FRONTEND_URL}/api/revalidate",
            json={"path": f"/transfer/{instance.slug}"},
            headers={"Authorization": f"Bearer {settings.REVALIDATE_SECRET}"},
            timeout=5,
        )
    except requests.RequestException:
        # A failed revalidation must not fail the save; the page will
        # refresh on its normal time-based schedule anyway.
        pass

The try/except and timeout matter. If the frontend is briefly unavailable, you don't want the admin save to fail too. Revalidation should be best-effort, never on the critical path.

On busy systems, push this into a task queue (Celery or similar) instead of calling it inline, so saving is never affected by network latency.

Tag-based revalidation

Sometimes one content change invalidates several pages. Editing a post's title affects the detail page, the index, and the "latest post" card on the homepage.

Rather than revalidating each path, tag the data:

// Tag when fetching
const posts = await fetch(`${API}/posts`, {
  next: { tags: ["posts"] },
}).then((r) => r.json());
// Invalidate all of them in one call
revalidateTag("posts");

This applies to setups that fetch via fetch. For content read from the filesystem (MDX and similar), revalidatePath is the right tool.

The common mistake: forgetting revalidate

The most frequent problem is simply not setting revalidate. The page freezes at build time and never updates no matter what you change. The symptom is familiar: "the database says one thing but the site shows another."

The quickest check is the next build output. Pages with a revalidation window are listed with it:

├ ● /[locale]/blog        1h      1y
│ ├ /tr/blog
│ └ /en/blog

The 1h on the right is the revalidation window. Blank means that page never revalidates.

Summary

ISR is how static sites serve dynamic content without giving up their speed. In practice, running both layers together works well: time-based revalidation as a safety net (everything refreshes within a known window) and on-demand revalidation for changes that need to be live immediately. If the webhook fails, the page still refreshes eventually.