Choosing ISR Revalidate Intervals per Content Type
A companion to Next.js ISR Implementation, this guide replaces the single site-wide revalidate: 60 with per-content-type windows derived from three measurable inputs: how often the content changes, how much traffic the route gets, and what a stale page costs.
Most sites pick one number early and never revisit it. That number is almost always wrong for most of the site: too short for evergreen documentation, which regenerates constantly for no benefit, and too long for prices or event times, where a stale page is a support ticket. The window is also widely misunderstood. It is not a maximum age, and on low-traffic routes the real staleness can be many times larger.
The Problem
Consider a documentation site with 3,000 pages on Sanity. The team sets revalidate: 60 everywhere because editors want fast updates. Traffic is uneven: a few hundred pages get most visits, and the long tail sees a handful per day. The result is the worst of both worlds. The popular pages regenerate every minute, each regeneration costing a GROQ query and a render, even though they change twice a month. Meanwhile a rarely visited page edited this morning still shows yesterday’s text to the first visitor this afternoon, because nobody requested it after the window elapsed until then. That visitor gets the old version and triggers regeneration for the next one.
The fix has two parts: choose windows per content type from real inputs, and stop relying on windows for freshness by adding on-demand revalidation with revalidateTag. With webhooks in place, the window’s only job is to bound staleness when a webhook is lost.
How to Derive a Window
Three inputs decide a sensible window for each content type.
Change frequency. How often does an entry of this type change after publishing? Pull it from the CMS: most platforms expose version history, so you can count revisions per entry over the last 90 days. Legal pages might change once a year, articles a few times in their first week, product prices daily.
Traffic per route. Requests per route per hour, from CDN logs or analytics. With traffic r requests per second and window w seconds, the expected extra staleness after the window is roughly 1/r seconds. A route with a request every two seconds regenerates almost exactly at the window; a route with three requests a day can be hours stale.
Cost of staleness. What happens if a reader sees the previous version for the whole window? For a typo, nothing. For a price, a support ticket or a legal problem. For an event time, a missed event. This input decides whether the window should be short or whether the route should skip ISR altogether and render dynamically.
Combine them with a simple rule. Start from the cost tier (low, medium or high), and within it choose a window shorter than the typical interval between changes. For high-cost content, prefer dynamic rendering with a short CDN cache over ISR, because no window is safe when a webhook can be lost.
Implementation
Encode the policy once and reference it everywhere, instead of scattering numbers across route files. A typed map from content type to window also makes the policy reviewable in code review and easy to change when the data says so.
// lib/revalidate-policy.ts
export type ContentType = "legalPage" | "docPage" | "article" | "landingPage" | "navigation";
interface Policy {
revalidate: number; // seconds; fallback when a webhook is lost
tags: (id?: string) => string[];
}
const HOUR = 3600;
export const POLICY: Record<ContentType, Policy> = {
legalPage: { revalidate: 24 * HOUR, tags: (id) => ["legalPage", ...(id ? [`legalPage:${id}`] : [])] },
docPage: { revalidate: HOUR, tags: (id) => ["docPage", ...(id ? [`docPage:${id}`] : [])] },
article: { revalidate: 15 * 60, tags: (id) => ["article", ...(id ? [`article:${id}`] : [])] },
landingPage: { revalidate: 5 * 60, tags: (id) => ["landingPage", ...(id ? [`landingPage:${id}`] : [])] },
// Navigation renders on every page, so it gets a type-level tag only.
navigation: { revalidate: HOUR, tags: () => ["navigation"] },
};
export async function cmsFetch<T>(type: ContentType, query: string, id?: string): Promise<T> {
const { revalidate, tags } = POLICY[type];
const res = await fetch(`${process.env.CMS_API_URL}/query?q=${encodeURIComponent(query)}`, {
headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
next: { revalidate, tags: tags(id) },
});
if (!res.ok) throw new Error(`CMS ${res.status} for ${type}`);
return (await res.json()) as T;
}
A page that combines several content types gets the shortest window among its fetches, because Next.js applies the lowest revalidate of all data on a route to the route itself. That is why navigation carries a relatively long window: if it were short, every page on the site would inherit that short window. Keep global content long-lived and invalidate it by tag.
Measure the inputs with a small script against your CMS and your CDN logs, then revisit the table quarterly:
# Revisions per article in the last 90 days (Contentful CMA; requires a management token)
curl -s -H "Authorization: Bearer $CMA_TOKEN" \
"https://api.contentful.com/spaces/$SPACE/environments/master/entries?content_type=article&sys.updatedAt[gte]=2026-06-20&limit=1000&select=sys.id,sys.version" \
| jq '[.items[].sys.version] | {entries: length, median_versions: (sort | .[length/2|floor])}'
Rolling Out a Policy Change
Changing windows on a live site is low-risk, but it is worth doing in a measured way, because the effect on CMS usage and origin load is immediate. A practical sequence:
- Baseline for a week. Record regenerations per hour (count
MISSandSTALEresponses at the origin), CMS API requests per hour and the publish-to-visible latency from your revalidation logs. - Wire on-demand revalidation first. Lengthening windows without webhooks makes content staler. With webhooks, it only reduces wasted work.
- Change one content type at a time. Start with the type that has the most wasted regenerations, usually documentation or evergreen articles, and compare the metrics after two or three days.
- Watch the shortest window on shared layouts. After each change, check that no widget or layout fetch pulls the route window back down. The effective window of a route is visible in the
Cache-Control: s-maxageheader of its response. - Document the policy. Keep the table in the repository next to the
POLICYmap, with the date and the data behind each number, so the next person to touch it knows why a value was chosen.
On the documentation site from the problem statement, this rollout cut regenerations by roughly two orders of magnitude, reduced GROQ queries enough to drop a CMS plan tier, and improved freshness on the long tail, because webhooks now reached pages that time-based windows had left stale for hours.
Configuration Reference
| Knob | Where | Effect |
|---|---|---|
export const revalidate = n |
route segment | Upper bound for the route; the lowest value on the route wins. |
fetch(url, { next: { revalidate } }) |
per request | Window for that data entry; also lowers the route window. |
revalidate: false |
segment or fetch | Cache indefinitely; rely entirely on on-demand invalidation. |
export const dynamic = "force-dynamic" |
route segment | Skip ISR for high-cost content; pair with a short CDN s-maxage. |
CDN s-maxage |
response header | Should not exceed the route window unless purges are wired. |
Gotchas & Edge Cases
- One short fetch shortens the whole route. A “latest news” widget with
revalidate: 30embedded in every article makes every article regenerate every 30 seconds. Move such widgets to client-side fetching or give them their own cached segment. revalidate: 0is not “no cache”. In the App Router, a zero window makes the route dynamic. Use it deliberately, not as a debugging shortcut left in production.- Scheduled publishing. Content scheduled to go live at 09:00 fires no webhook on some platforms, or fires it at publish time with delivery lag. Give time-sensitive types a short window as a backstop, or trigger revalidation from a cron that reads the schedule.
- Build-time generation versus windows. Pages generated at build are stamped with the build time. After a deploy, every page starts a fresh window at once, which can cause a regeneration burst when the windows expire together. Staggering is rarely necessary, but watch CMS rate limits after large deploys.
Frequently Asked Questions
Is a shorter window always fresher?
Only on routes with steady traffic. On a quiet route, the page’s age depends on when the previous visitor arrived, not on the window. On-demand revalidation is the only mechanism that makes quiet routes fresh.
What window should I use if webhooks are fully reliable?
revalidate: false is defensible in that case: cache forever and invalidate by tag. In practice webhooks are occasionally lost to timeouts or misconfiguration, so a long safety window of an hour or a day costs almost nothing and bounds the damage.
How does the CDN in front of Next.js change these numbers?
The CDN adds its own TTL on top of the window. If the CDN caches HTML for ten minutes and never receives purges, the effective staleness is the window plus ten minutes. Either purge the CDN on publish, as described in distributed CDN invalidation, or keep the CDN TTL short.