Inksh logoContactAbout usPrivacy
Author name Hemant Bhatt

The Next.js ISR Handbook

Inktober-style illustration of a bakery representing ISR — fresh bread being baked in the background while customers are served

Imagine you run a bakery. Every morning you bake fresh bread, line it up on the shelves, and open the doors. Customers walk in, grab what they need, and leave happy.
Fast service—everything's pre-made.

But what happens when you run out of sourdough at 2 PM?
You have two options: close the shop, bake everything from scratch, and reopen (that's a full rebuild). Or you keep the shop open, keep serving what you have, and bake a fresh batch in the back while nobody notices.

That second option? That's Incremental Static Regeneration.

Why You Should Care:

Your website rebuilds itself while you're sleeping. A product gets updated in the database, a blog post gets published, a price changes—and the next visitor sees the fresh version. No deploy. No CI pipeline. No human hitting a button.

Cut the Bill

SSR runs a function on every request.

ISR serves a cached static page and only rebuilds when the content is actually stale. Your function duration bill drops.

Kill Build Times

Ten thousand pages? Forty-five minute build?

Pre-build the popular ones. Generate the rest on demand. Build times go from "lunch break" to "sip of coffee."

Dial the Freshness

Some pages need real-time. Some are fine being stale.

ISR gives you a revalidation knob—you decide the time interval for each route.

Websites are complex. Rendering decisions are driven by budgets, traffic patterns, and how stale your users will tolerate the data being. ISR is the tool that lets you make those decisions per-page instead of all-or-nothing.

generateStaticParams

If you've ever worked in a kitchen, you know the term mise en place—everything in its place before service starts. Vegetables chopped. Sauces reduced. Proteins portioned. When the first order hits, you're not scrambling. You're assembling.

generateStaticParams is mise en place for your website. You tell Next.js: "Here are all the pages I know about. Prep them before anyone shows up." At build time, each one gets rendered into static HTML. When a user arrives, the page is already plated and waiting.

The Prep List

You export an async function from your dynamic route's page.tsx. It returns an array of parameter objects—one per page you want pre-built. Each object is a dish that's ready before the doors open. Zero compute at request time.

Say you run a documentation site with hundreds of guides, each living at /docs/[slug]. Here's the full picture:

app/docs/[slug]/page.tsx
1// app/docs/[slug]/page.tsx
2
3interface Guide {
4 slug: string;
5 title: string;
6 content: string;
7 updatedAt: string;
8}
9
10// Revalidate every 30 minutes
11export const revalidate = 1800;
12
13export async function generateStaticParams() {
14 const res = await fetch("https://api.myapp.dev/guides");
15 const guides: Guide[] = await res.json();
16
17 // Each object here becomes a pre-built page
18 return guides.map((guide) => ({
19 slug: guide.slug,
20 }));
21}
22
23export default async function GuidePage({
24 params,
25}: {
26 params: Promise<{ slug: string }>;
27}) {
28 const { slug } = await params;
29 const res = await fetch(`https://api.myapp.dev/guides/${slug}`);
30 const guide: Guide = await res.json();
31
32 return (
33 <article>
34 <h1>{guide.title}</h1>
35 <p>Last updated: {guide.updatedAt}</p>
36 <div dangerouslySetInnerHTML={{ __html: guide.content }} />
37 </article>
38 );
39}

At build time, Next.js calls generateStaticParams, loops through every guide, and pre-renders each page. When a user visits /docs/getting-started, the HTML is already plated. No function invocation. No database call. Just a file served from the edge.

The revalidate Export

Notice the one-liner in the example above? That's where ISR kicks in:

app/docs/[slug]/page.tsx
1export const revalidate = 1800; // 30 minutes

That number is the time-to-live for the cached page. For 30 minutes, every visitor gets the pre-built static version. After that window closes, the next request triggers a background rebuild while still serving the cached version instantly.

Think of it as the shelf life on your prepped ingredients. The vinaigrette is good for 30 minutes. After that, a cook whips up a fresh batch in the back—while the front of house keeps serving the current one. Nobody waits.

dynamicParams: The Walk-In Order

Your prep list covered 80 guides. But someone just published guide #81—a dish that wasn't part of the original mise en place. A user hits /docs/new-feature. What happens?

app/docs/[slug]/page.tsx
1// Build the page on first visit, cache it for everyone after
2export const dynamicParams = true; // default
3
4// Reject unknown slugs with a 404
5export const dynamicParams = false;

With true, the kitchen fires off the new dish on demand. First visitor waits for it to cook, but after that it's prepped and cached for everyone. The menu grows organically.

With false, the kitchen refuses walk-in orders. If it wasn't on the prep list, it's a 404. Locked down. Useful when you control every URL and don't want surprises.

Controlled set of pages, nothing else gets through?

dynamicParams = false

Content keeps growing and new pages should just work?

dynamicParams = true (default)

The Revalidation Sequence

This is the part most developers get wrong—or at least get fuzzy on. Let me walk you through exactly what happens, step by step.

Act 1: The Fresh Page

You deploy your site. Next.js has pre-built /products/blue-sneakers with the data it fetched at build time. The page is cached and ready. Every visitor gets the static version. Fast. Zero compute.

Act 2: The Clock Runs Out

Your revalidate is set to 3600 seconds. An hour passes. The page is now stale—but it's still cached. Nothing happens yet. Nobody's coming to the bakery.

Act 3: The Trigger

A visitor hits /products/blue-sneakers. Next.js checks the clock. The page is stale.

Here's the critical part: Next.js still serves the old page. The visitor doesn't wait. They get the stale-but-cached version instantly. But in the background, Next.js starts rebuilding—re-fetching the data, re-rendering the component, generating fresh HTML.

Act 4: The Swap

The background rebuild finishes. Next.js replaces the cached page with the new one. Silently. No downtime. No flicker.

Act 5: The Payoff

The next visitor hits /products/blue-sneakers. They get the freshly rebuilt page. New data. New HTML. And the clock starts ticking again.

The Pattern:

This is called stale-while-revalidate. Serve the old, build the new, swap when ready.

The Safety Net:

If the background rebuild fails—the API is down, the database throws an error, something breaks—Next.js keeps serving the last successfully built page. No error page. No blank screen. The old bread is better than no bread. Your users never see the failure.

Manual Revalidation

Time-based revalidation is great for content that drifts slowly—blog posts, product catalogs, documentation. But sometimes you need a page to update right now. A product price changed. An article was edited. You don't want to wait for the timer.

revalidatePath

Invalidate a specific route. The next visitor triggers a fresh build.

You can call it from a Server Action:

app/actions/revalidate.ts
1// app/actions/revalidate.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5
6export async function purgeProductPage(path: string, secret: string) {
7 if (secret !== process.env.REVALIDATION_SECRET) {
8 throw new Error("Unauthorized");
9 }
10
11 revalidatePath(path);
12}

Or from a Route Handler:

app/api/revalidate-path/route.ts
1// app/api/revalidate-path/route.ts
2import { revalidatePath } from "next/cache";
3import { NextRequest, NextResponse } from "next/server";
4
5export async function POST(request: NextRequest) {
6 const { path, secret } = await request.json();
7
8 if (secret !== process.env.REVALIDATION_SECRET) {
9 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10 }
11
12 revalidatePath(path);
13 return NextResponse.json({ revalidated: true });
14}

Same result either way. The cached version of that path is purged. The next visitor triggers a fresh build.

revalidateTag

Sometimes one data change affects multiple pages. A product's price updates, and it shows on the product page, the category page, and the homepage featured section. Revalidating each path manually? Tedious.

Tags let you group cache entries. When you fetch data, tag it. When the data changes, revalidate the tag—and every page using that data rebuilds.

app/products/[slug]/page.tsx
1// app/products/[slug]/page.tsx
2export default async function ProductPage({
3 params,
4}: {
5 params: Promise<{ slug: string }>;
6}) {
7 const { slug } = await params;
8
9 const res = await fetch(`https://api.mystore.com/products/${slug}`, {
10 next: { tags: [`product-${slug}`, "all-products"] },
11 });
12 const product = await res.json();
13
14 return (
15 <article>
16 <h1>{product.name}</h1>
17 <p>${product.price}</p>
18 </article>
19 );
20}
app/actions/products.ts
1// app/actions/products.ts
2"use server";
3
4import { revalidateTag } from "next/cache";
5
6export async function updateProductPrice(slug: string, newPrice: number) {
7 await fetch(`https://api.mystore.com/products/${slug}`, {
8 method: "PATCH",
9 body: JSON.stringify({ price: newPrice }),
10 headers: { "Content-Type": "application/json" },
11 });
12
13 // Invalidate everything tagged with this product
14 revalidateTag(`product-${slug}`);
15}

One revalidateTag call. Every page that fetched data with that tag gets rebuilt on the next request. Clean.

The Webhook Pattern

Both revalidatePath and revalidateTag work in Server Actions and Route Handlers. A common pattern: set up a webhook route that your CMS calls whenever content changes.

app/api/revalidate/route.ts
1// app/api/revalidate/route.ts
2import { revalidateTag } from "next/cache";
3import { NextRequest, NextResponse } from "next/server";
4
5export async function POST(request: NextRequest) {
6 const { tag, secret } = await request.json();
7
8 if (secret !== process.env.REVALIDATION_SECRET) {
9 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10 }
11
12 revalidateTag(tag);
13 return NextResponse.json({ revalidated: true });
14}

Your CMS publishes an article → hits your webhook → the relevant pages rebuild. No deploy needed.

Debugging ISR

Here's a scenario that will ruin your week: you set up ISR, everything looks right in development, you deploy, and three months later you realize nothing was ever cached. Every request was hitting your server. Your hosting bill looks like a phone number.

Trust, but Verify:

Don't assume your caching works. Prove it.

Next.js gives you a debug flag that logs every cache hit and miss to your server console:

.env
1NEXT_PRIVATE_DEBUG_CACHE=1

Caveats

ISR in the App Router has some sharp edges. Here's where developers get tripped up.

Caveat #1: Dynamic Layouts Kill ISR

Problem:

If your layout.tsx is dynamic, every page under it becomes dynamic. Your ISR configuration on the page doesn't matter. The layout wins.

app/products/layout.tsx
1// app/products/layout.tsx
2import { cookies } from "next/headers";
3
4export default async function ProductLayout({
5 children,
6}: {
7 children: React.ReactNode;
8}) {
9 const cookieStore = await cookies(); // ← This makes the layout dynamic
10 const theme = cookieStore.get("theme")?.value;
11
12 return <div className={theme}>{children}</div>;
13}
app/products/[slug]/page.tsx
1// app/products/[slug]/page.tsx
2export const revalidate = 3600; // ← Doesn't matter. Layout is dynamic.

Solution:

Move dynamic logic out of the layout. Use middleware for theme detection, or push the cookie read into a Client Component.

Caveat #2: The Lowest Revalidation Wins

The Trap:

When your layout and page both export revalidate, the lower number becomes the effective revalidation interval for the whole route.

Route revalidation
1// app/products/layout.tsx
2export const revalidate = 60; // 1 minute
3
4// app/products/[slug]/page.tsx
5export const revalidate = 3600; // 1 hour
6
7// Effective revalidation? 60 seconds. Not an hour.

The layout's shorter interval pulls the whole route down with it. This is by design—it ensures the freshest configuration always wins. But it can surprise you if you set a long revalidation on a page without checking what the layout is doing.

Caveat #3: Dynamic Layout + Cached Fetches

Picture this: your layout.tsx reads cookies for auth. Your page.tsx fetches product data with next: { revalidate: 300 }. You'd expect the page to be statically cached and rebuilt every 5 minutes, right?

Not quite. Next.js has two independent caching layers, and a dynamic layout breaks one while leaving the other untouched:

  • Full Route Cache — Stores the finished HTML and RSC payload so the server doesn't have to render at all. A dynamic layout disables this entirely. The server re-renders the route from scratch on every single request.
  • Data Cache — Stores individual fetch responses. This one keeps working regardless of whether the route is dynamic. Your revalidate: 300 still controls how long fetch results are cached.

Here's what that looks like in code:

app/dashboard/layout.tsx
1// app/dashboard/layout.tsx
2import { cookies } from "next/headers";
3
4export default async function DashboardLayout({
5 children,
6}: {
7 children: React.ReactNode;
8}) {
9 const cookieStore = await cookies();
10 const session = cookieStore.get("session");
11 // ⚠️ cookies() makes the layout dynamic
12 // → Full Route Cache is OFF for this entire route
13 return <div>{children}</div>;
14}
app/dashboard/stats/page.tsx
1// app/dashboard/stats/page.tsx
2export default async function StatsPage() {
3 const res = await fetch("https://api.mystore.com/stats", {
4 next: { revalidate: 300 }, // Data Cache: 5 minutes
5 });
6 const stats = await res.json();
7
8 return <div>{stats.totalSales}</div>;
9}

What happens on each request:

Every request: The server runs your React components from scratch—layout, page, the whole tree. There's no cached HTML to serve. That's the cost of a dynamic layout.

Within 5 minutes: During that render, the fetch call hits the Data Cache and returns instantly—no network request to your API. The render is fast, even though it happens every time.

After 5 minutes: The Data Cache entry is stale. The next render still gets the old cached data (no waiting), but triggers a background refresh. The request after that gets fresh data.

The result: you're paying for server-side render compute on every request (no cached HTML), but you're not paying for the API call on every request (cached fetch data). You get the data freshness benefits of ISR, but not the performance benefits of serving pre-built static pages.

The Takeaway:

Full ISR = cached HTML + cached data. A dynamic layout kills the HTML cache. You keep the data cache, but lose the biggest win: zero render cost per request. Keep layouts static if you want the full benefit.

Conclusion

Here's the cheat sheet:

  • generateStaticParams pre-builds pages at build time. It's your guest list.
  • revalidate sets the cache lifetime. It's your freshness knob.
  • dynamicParams controls whether new pages can be generated on demand or get a 404.
  • Time-based revalidation uses stale-while-revalidate: serve the old, build the new, swap when ready.
  • On-demand revalidation with revalidatePath and revalidateTag lets you trigger rebuilds immediately.
  • NEXT_PRIVATE_DEBUG_CACHE=1 proves your caching actually works. Use it.
  • Dynamic layouts kill ISR. Keep layouts static if you want the full benefit.