This site is not affiliated with or endorsed by Vercel, Inc. Read the docs, then deploy or run each experiment yourself.
Rendering

ISR and Revalidation Playground

Explore static rendering, revalidation intervals, on-demand refresh, and content freshness timelines.

Run this experiment yourself

Demos are not embedded on this site. Deploy a standalone copy on Vercel or run the experiment app locally.

Local development

cd apps/experiments/isr-revalidation-playground
pnpm install
pnpm dev

Then open http://localhost:3010.

This is an experimental demo. Use it as a starting point for your own projects.

The ISR and Revalidation Playground teaches how Incremental Static Regeneration (ISR) keeps a page static while still letting it refresh over time. It is built with Next.js, React client state, a small pure TypeScript state machine (apps/experiments/isr-revalidation-playground/logic.ts), and one Route Handler (app/api/isr-demo/route.ts). It has no external services and needs no environment variables.

The problem it addresses is the mental model gap between a revalidate number and what a visitor actually sees: when is a page fresh, when is it stale, and what changes when you trigger an on-demand refresh? The experiment offers two clearly separated modes:

  • Simulated timeline - a deterministic, educational state machine. It is not telemetry from Vercel or from Next.js.
  • Real demo route - a real fetch against /api/isr-demo, showing the actual Cache-Control header this app's Route Handler sets for a given revalidate value. It is a header demo, not an ISR page.

Features

  • Configure revalidate (seconds) and see the matching export const revalidate = N; line.
  • Step through a simulated timeline of 2–12 ticks, with 1–120 seconds per tick.
  • Optionally inject an on-demand revalidation at a chosen tick to see the cache entry reset.
  • Switch to Real demo route mode to call GET /api/isr-demo and inspect status, cache-control, and the JSON body.
  • Copy a generated App Router snippet using export const revalidate.
  • Reset button restores the defaults (revalidate=60, 6 ticks, 30 s per tick, simulated mode).

API Reference

GET /api/isr-demo

Returns a small JSON body and sets Cache-Control derived from the revalidate query parameter. The route reads request.nextUrl.searchParams, so it is dynamic.

Prop

Type

Request

curl -i "http://localhost:3000/api/isr-demo?revalidate=60"

Response - revalidate greater than 0

HTTP/1.1 200 OK
content-type: application/json
x-isr-demo: 1
cache-control: public, s-maxage=60, stale-while-revalidate=120
{
  "revalidate": 60,
  "generatedAt": "2026-10-11T05:56:00.000Z",
  "note": "Demo API route illustrating revalidate-style cache headers - not Vercel platform metrics."
}

The stale-while-revalidate value is always revalidate * 2.

Response - revalidate=0

HTTP/1.1 200 OK
content-type: application/json
x-isr-demo: 1
cache-control: no-store
{
  "revalidate": 0,
  "generatedAt": "2026-10-11T05:56:00.000Z",
  "note": "Demo API route illustrating revalidate-style cache headers - not Vercel platform metrics."
}

Errors

This route does not return 4xx responses. Invalid input is handled by falling back to the default:

# Out of range, fractional, or non-numeric values all behave like revalidate=60
curl -i "http://localhost:3000/api/isr-demo?revalidate=999999"
curl -i "http://localhost:3000/api/isr-demo?revalidate=abc"
{
  "revalidate": 60,
  "generatedAt": "2026-10-11T05:56:00.000Z",
  "note": "Demo API route illustrating revalidate-style cache headers - not Vercel platform metrics."
}

The headers shown are exactly what the handler sets. In production, the CDN and any intermediary caches apply their own rules, so what a browser sees and what is cached at the edge are not guaranteed to match this output. Treat the route as a way to read and compare header values, not as a measurement of Vercel cache behavior.

Implementation Details

Request / flow

Choose a revalidate interval

The demo component passes the number through revalidateAfter, which floors the value and clamps it to be non-negative:

export function revalidateAfter(seconds: number): { revalidate: number } {
  const revalidate = Math.max(0, Math.floor(seconds));
  return { revalidate };
}

Classify freshness

getFreshnessState compares the age of the generated page with the interval. A revalidate of 0 is always treated as stale.

export function getFreshnessState(
  generatedAtSeconds: number,
  nowSeconds: number,
  revalidateSec: number,
  revalidating: boolean,
): IsrFreshnessState {
  if (revalidating) return 'revalidating';
  const age = nowSeconds - generatedAtSeconds;
  if (revalidateSec <= 0) return 'stale';
  if (age < revalidateSec) return 'fresh';
  return 'stale';
}

Walk the timeline

simulateIsrTimeline loops over ticks. Tick 1 is the initial static generation. On the on-demand tick, the generated-at time resets to the current elapsed time. When a tick finds the page stale (and it is not the last tick), the simulator reports revalidating and immediately treats the page as regenerated for the next tick.

if (onDemand) {
  generatedAt = elapsed;
  revalidating = false;
}

let state = getFreshnessState(generatedAt, elapsed, revalidateSec, revalidating);

// ...
} else if (state === 'stale') {
  note = `Stale static HTML (age ${elapsed - generatedAt}s ≥ revalidate). Next request may trigger background regeneration.`;
  if (revalidateSec > 0 && tick < tickCount) {
    revalidating = true;
    state = 'revalidating';
    note = 'Background revalidation in progress - stale page may still be served.';
    generatedAt = elapsed;
    revalidating = false;
  }
}

Optionally probe the real route

In Real demo route mode the client calls the Route Handler with cache: 'no-store' so the browser does not reuse an earlier response, then prints the status, cache-control header, and body:

const res = await fetch(`/api/isr-demo?revalidate=${config.revalidate}`, {
  cache: 'no-store',
});

Default timeline

With the defaults (revalidate=60, 6 ticks, 30 s per tick, no on-demand tick) the state machine produces this sequence (verified by running simulateIsrTimeline(60, 6, 30)):

TickElapsedStateNote
10 sfreshStatic page generated at build or first request.
230 sfreshServed from cache (age 30s < revalidate 60s).
360 srevalidatingBackground revalidation in progress - stale page may still be served.
490 sfreshServed from cache (age 30s < revalidate 60s).
5120 srevalidatingBackground revalidation in progress - stale page may still be served.
6150 sfreshServed from cache (age 30s < revalidate 60s).

Setting On-demand revalidate at tick to 4 makes tick 4 report On-demand revalidate: cache entry regenerated now. and resets the age clock at that point.

Route Handler source

const querySchema = z.object({
  revalidate: z.coerce.number().int().min(0).max(86400).default(60),
});

export async function GET(request: NextRequest) {
  const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
  const revalidateSec = parsed.success ? parsed.data.revalidate : 60;
  const { revalidate } = revalidateAfter(revalidateSec);

  // ...
  if (revalidate === 0) {
    headers['cache-control'] = 'no-store';
  } else {
    headers['cache-control'] = `public, s-maxage=${revalidate}, stale-while-revalidate=${revalidate * 2}`;
  }

  return NextResponse.json(body, { headers });
}

Use Cases

  • Teaching teammates the difference between a fresh page, a stale page, and a page being regenerated.
  • Comparing what a short interval (for example 10 s) and a long interval (for example 3600 s) mean for content freshness.
  • Showing why an on-demand refresh is useful after a CMS publish instead of waiting for the interval to expire.
  • Quickly generating a Cache-Control header with s-maxage and stale-while-revalidate values to reuse in your own API routes.

Limitations

Security notes from the registry:

  • Simulated freshness timeline is educational.
  • Real route mode only reflects this app's demo handler headers.

Limits and behaviors taken from the code:

  • The simulator does not model concurrent requests, CDN regions, or request timing. It advances one tick at a time.
  • The revalidating state is a presentation step: the simulator marks the stale tick as revalidating and treats the page as regenerated before the next tick. stale therefore only appears on the final tick, or on every tick when revalidate is 0 (tick 1 shows the "generated" note but still reports stale).
  • On-demand revalidation is a tick input in the simulator only. The demo does not call revalidatePath or revalidateTag. A helper, onDemandRevalidate, exists in logic.ts but the demo UI does not use it.
  • /api/isr-demo is a Route Handler that sets headers. It does not render or regenerate a statically cached page.
  • The UI inputs declare ranges (revalidate 0–3600, ticks 2–12, seconds per tick 1–120), but they are plain HTML attributes. The API accepts 0–86400.
  • The generated snippet uses export const revalidate = N. The registry also lists Cache Components as a technology; if you enable cacheComponents in your own project, follow the Next.js guide for ISR with Cache Components and use use cache with cacheLife instead of route segment revalidate. This app does not enable cacheComponents in next.config.mjs.

Use in your project

Copy the state machine from logic.ts into a utility or a learning page, and the header logic into any Route Handler:

// lib/isr.ts
export type IsrFreshnessState = 'fresh' | 'stale' | 'revalidating';

export function getFreshnessState(
  generatedAtSeconds: number,
  nowSeconds: number,
  revalidateSec: number,
  revalidating: boolean,
): IsrFreshnessState {
  if (revalidating) return 'revalidating';
  const age = nowSeconds - generatedAtSeconds;
  if (revalidateSec <= 0) return 'stale';
  return age < revalidateSec ? 'fresh' : 'stale';
}

export function cacheControlFor(revalidate: number): string {
  if (revalidate <= 0) return 'no-store';
  return `public, s-maxage=${revalidate}, stale-while-revalidate=${revalidate * 2}`;
}
// app/api/example/route.ts
import { NextResponse } from 'next/server';
import { cacheControlFor } from '@/lib/isr';

export async function GET() {
  return NextResponse.json(
    { generatedAt: new Date().toISOString() },
    { headers: { 'cache-control': cacheControlFor(60) } },
  );
}

For a real ISR page, the App Router snippet the demo generates looks like this:

// app/blog/[slug]/page.tsx
export const revalidate = 60;

export default async function Page() {
  const posts = await fetchPosts();
  return <ArticleList posts={posts} />;
}

Deployment

Deploy on Vercel

This experiment needs no Marketplace stores and no environment variables, so it works with a plain Vercel deploy. Use the Deploy button on this experiment page. No deploymentUrl is registered for this experiment specifically.

Local Development

pnpm install
pnpm dev

Open the experiment page, then try the real route directly:

# Default interval (60)
curl -i "http://localhost:3000/api/isr-demo"

# Short interval
curl -i "http://localhost:3000/api/isr-demo?revalidate=10"

# Disable caching
curl -i "http://localhost:3000/api/isr-demo?revalidate=0"

The pure logic is covered by apps/experiments/isr-revalidation-playground/logic.test.ts:

pnpm test

Configuration

ItemRequiredNotes
Environment variablesNoNone are read by this experiment.
External servicesNoNone.
revalidate query paramNoInteger 0–86400, defaults to 60.

Vercel / Next.js Features Used

Next Steps

On this page