Static & ISR

Prerender once, serve from Vercel's cache, regenerate on a timer or on demand.

What it is

This whole page is static: it touches no request data, so it's rendered at build time and served from Vercel's cache. With Cache Components you opt data into the cache with "use cache" and choose how long it lives with cacheLife(). Incremental Static Regeneration (ISR) refreshes cached content without a redeploy: on a timer, or on demand by invalidating a cacheTag() with updateTag() or revalidateTag().

When to use it

  • Marketing pages, docs and blogs: static, with an occasional refresh
  • CMS content: cache forever, revalidate from the CMS webhook on publish
  • Product pages: prerender the popular ones, generate the rest on first visit
Official docs ↗

1. Static: built once

Cached with cacheLife("max"). This timestamp stays until the next deploy (or until the cache entry is evicted).

Built at 2026-10-17T19:39:35.785Z

2. On-demand ISR: refresh when the data changes

Cached forever with tag static-on-demand. The button calls a Server Action that runs updateTag(): the cache entry expires at once and you see fresh values straight away (read-your-own-writes).

Generated at2026-10-17T19:39:35.785Z
Lucky number13

A CMS would call the webhook instead: POST /api/static/revalidate with Authorization: Bearer $REVALIDATE_SECRET. It runs revalidateTag(tag, "max"), which marks the entry stale: the next visitor gets the old copy while the new one is built.

3. Time-based ISR on dynamic routes

/static/[id] uses generateStaticParams() to prerender items 1 to 3 at build time. Any other id up to 50 is generated on its first visit (watch it take a moment), then cached. Each item uses cacheLife("minutes"): after a minute the next visitor still gets the cached copy instantly while Vercel regenerates it in the background (stale-while-revalidate), so the visit after that shows a new timestamp.

Item 1 (prerendered)Item 2 (prerendered)Item 3 (prerendered)Item 42 (on demand)

Show code · src/app/static/page.tsx
import { cacheLife, cacheTag } from "next/cache";
import Link from "next/link";
import { CodePeek } from "@/components/CodePeek";
import { Demo, FeaturePage } from "@/components/FeaturePage";
import { ON_DEMAND_TAG } from "@/lib/revalidate";
import { refreshNow } from "./actions";

// Each function below is cached with "use cache". A page's cache life is the
// shortest of everything on it, so the timed demo lives on /static/[id]: here
// both entries last until the next deploy or until their tag is invalidated.

async function builtAt() {
  "use cache";
  cacheLife("max");
  return new Date().toISOString();
}

// Cached until someone invalidates the tag (Server Action or webhook below).
async function onDemand() {
  "use cache";
  cacheLife("max");
  cacheTag(ON_DEMAND_TAG);
  return { generatedAt: new Date().toISOString(), lucky: Math.floor(Math.random() * 100) };
}

function Snapshot({ generatedAt, lucky }: { generatedAt: string; lucky: number }) {
  return (
    <div className="cells">
      <div className="cell"><span className="small muted">Generated at</span><strong className="stamp">{generatedAt}</strong></div>
      <div className="cell"><span className="small muted">Lucky number</span><strong className="stamp">{lucky}</strong></div>
    </div>
  );
}

export default async function StaticPage() {
  const [built, tagged] = await Promise.all([builtAt(), onDemand()]);
  return (
    <FeaturePage
      title="Static & ISR"
      tagline="Prerender once, serve from Vercel's cache, regenerate on a timer or on demand."
      docs="https://nextjs.org/docs/app/getting-started/revalidating"
      whatItIs={<p>
        This whole page is <strong>static</strong>: it touches no request data, so it&apos;s rendered at build time and served from
        Vercel&apos;s cache. With Cache Components you opt data into the cache with <code>&quot;use cache&quot;</code> and choose how long
        it lives with <code>cacheLife()</code>. <strong>Incremental Static Regeneration</strong> (ISR) refreshes cached content
        without a redeploy: on a timer, or on demand by invalidating a <code>cacheTag()</code> with <code>updateTag()</code> or{" "}
        <code>revalidateTag()</code>.
      </p>}
      whenToUse={[
        "Marketing pages, docs and blogs: static, with an occasional refresh",
        "CMS content: cache forever, revalidate from the CMS webhook on publish",
        "Product pages: prerender the popular ones, generate the rest on first visit",
      ]}
    >
      <Demo title="1. Static: built once">
        <p>Cached with <code>cacheLife(&quot;max&quot;)</code>. This timestamp stays until the next deploy (or until the cache entry is evicted).</p>
        <p>Built at <strong className="stamp">{built}</strong></p>
      </Demo>

      <Demo title="2. On-demand ISR: refresh when the data changes">
        <p>
          Cached forever with tag <code>{ON_DEMAND_TAG}</code>. The button calls a <strong>Server Action</strong> that runs{" "}
          <code>updateTag()</code>: the cache entry expires at once and you see fresh values straight away (read-your-own-writes).
        </p>
        <Snapshot {...tagged} />
        <form action={refreshNow}>
          <button type="submit">Regenerate now</button>
        </form>
        <p className="small muted">
          A CMS would call the webhook instead: <code>POST /api/static/revalidate</code> with{" "}
          <code>Authorization: Bearer $REVALIDATE_SECRET</code>. It runs <code>revalidateTag(tag, &quot;max&quot;)</code>, which marks
          the entry stale: the next visitor gets the old copy while the new one is built.
        </p>
      </Demo>

      <Demo title="3. Time-based ISR on dynamic routes">
        <p>
          <code>/static/[id]</code> uses <code>generateStaticParams()</code> to prerender items 1 to 3 at build time. Any other id up
          to 50 is generated on its first visit (watch it take a moment), then cached. Each item uses{" "}
          <code>cacheLife(&quot;minutes&quot;)</code>: after a minute the next visitor still gets the cached copy instantly while Vercel
          regenerates it in the background (stale-while-revalidate), so the visit after that shows a new timestamp.
        </p>
        <p>
          {[1, 2, 3].map((id) => <Link key={id} href={`/static/${id}`} style={{ marginRight: "0.75rem" }}>Item {id} (prerendered)</Link>)}
          <Link href="/static/42">Item 42 (on demand)</Link>
        </p>
      </Demo>

      <CodePeek file="app/static/page.tsx" />
      <CodePeek file="app/static/actions.ts" />
      <CodePeek file="app/api/static/revalidate/route.ts" />
      <CodePeek file="app/static/[id]/page.tsx" />
    </FeaturePage>
  );
}
Show code · src/app/static/actions.ts
"use server";

import { updateTag } from "next/cache";
import { ON_DEMAND_TAG } from "@/lib/revalidate";

// A Server Action: a function the browser can call, running on the server.
// updateTag() expires the tagged cache entry immediately, so the page that
// re-renders after this action already shows fresh data.
// It only regenerates a timestamp, so it's safe to leave open to visitors.
export async function refreshNow() {
  updateTag(ON_DEMAND_TAG);
}
Show code · src/app/api/static/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { ON_DEMAND_TAG, isAuthorized } from "@/lib/revalidate";

// Revalidation webhook, as a CMS would call it after publishing:
//   curl -X POST https://<host>/api/static/revalidate -H "Authorization: Bearer $REVALIDATE_SECRET"
// revalidateTag(tag, "max") marks the entry stale: the next visitor still gets
// the cached copy while a fresh one is built in the background.
export async function POST(request: Request) {
  const secret = process.env.REVALIDATE_SECRET;
  if (!secret) return Response.json({ error: "REVALIDATE_SECRET is not configured" }, { status: 503 });
  if (!isAuthorized(request.headers.get("authorization"), secret)) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
  revalidateTag(ON_DEMAND_TAG, "max");
  return Response.json({ revalidated: true, tag: ON_DEMAND_TAG, at: new Date().toISOString() });
}
Show code · src/app/static/[id]/page.tsx
import { cacheLife } from "next/cache";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { delay } from "@/lib/delay";

// Prerendered at build time. Other ids are generated on their first visit.
export function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }, { id: "3" }];
}

// Only ids 1–50 exist, so random URLs can't fill the cache.
const isValidId = (id: string) => /^\d+$/.test(id) && Number(id) >= 1 && Number(id) <= 50;

// The slow "database read" is cached per id: the first visit pays for it,
// everyone after gets the cached result. "minutes" regenerates it in the
// background at most once a minute (time-based ISR).
async function getItem(id: string) {
  "use cache";
  cacheLife("minutes");
  await delay(1200);
  return { id, generatedAt: new Date().toISOString() };
}

// Reads params inside <Suspense>, so the page shell can be served instantly
// even for ids that weren't prerendered.
async function Item({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  if (!isValidId(id)) notFound();
  const item = await getItem(id);
  return (
    <>
      <h1>Item {item.id}</h1>
      <p>Generated at <strong className="stamp">{item.generatedAt}</strong></p>
      <p className="muted">Reload: the timestamp stays the same, because this page comes from the cache. After a minute, one reload triggers a background refresh and the next one shows the new time.</p>
    </>
  );
}

export default function ItemPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <article className="feature">
      <section className="demo">
        <Suspense fallback={<p className="muted">Generating this item for the first time…</p>}>
          <Item params={params} />
        </Suspense>
        <Link href="/static">← Back to Static &amp; ISR</Link>
      </section>
    </article>
  );
}