How Next.js ISR and On-Demand Revalidation Work
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.
| Approach | When the page is built | Speed | Content freshness |
|---|---|---|---|
| SSG | Once, at build time | Fastest | Never changes without a redeploy |
| SSR | On every request | Slowest, costs server time | Always current |
| ISR | At build time + regenerated on a schedule or trigger | Same as SSG | Updates 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 hourThis 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 type | Reasonable revalidate |
|---|---|
| Blog index | 3600 (1 hour) |
| Product price | 300 (5 minutes) |
| Campaign page | 60 (1 minute) |
| About page | 86400 (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 requestdynamicParams 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:
- The endpoint must be protected. Left open, anyone can trigger it and hammer your app into a regeneration loop.
- Send the secret in a header, not a query string. Query strings end up in server logs, browser history, and referrer headers.
- 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.
passThe 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.