MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr ๐Ÿ”ฅ tagline

Hey there ๐Ÿ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 โ€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 โ€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms โ€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js ISR: Incremental Static Regeneration Explained

Master ISR: on-demand revalidation, stale-while-revalidate, and caching strategies.

Next.jsISRSSGPerformance

By MinhVo

Introduction

Static Site Generation (SSG) delivers blazing-fast page loads by serving pre-rendered HTML from a CDN. But traditional SSG has a fundamental problem: your content is frozen at build time. If you have 10,000 blog posts, rebuilding the entire site when one post changes is wasteful and slow. Incremental Static Regeneration (ISR) solves this by allowing individual pages to be regenerated independently, either on a time schedule or on-demand.

ISR is Next.js's most powerful rendering strategy for content-heavy sites. It combines the instant load times of static generation with the flexibility of dynamic rendering, all while keeping server costs low. This guide explains how ISR works under the hood, when to use it over other strategies, and how to implement advanced patterns like on-demand revalidation with webhooks.

ISR stale-while-revalidate pattern

Understanding ISR: Core Concepts

The Stale-While-Revalidate Pattern

ISR implements the HTTP stale-while-revalidate caching strategy at the page level:

Timeline:
T=0     โ†’ Page built and cached (fresh)
T=0-60  โ†’ Requests served from cache (fast, no server work)
T=61    โ†’ First request after expiry: serve stale + trigger rebuild
T=62    โ†’ Rebuild complete, cache updated
T=62+   โ†’ Requests served from fresh cache

This means users never wait for a page to regenerate โ€” they always get a cached response, even during revalidation. The tradeoff is that the very first visitor after the revalidation period sees slightly stale content, but the next visitor gets the fresh version.

Time-Based Revalidation

The simplest ISR configuration โ€” revalidate after N seconds:

// app/blog/[slug]/page.tsx
export const revalidate = 60; // Revalidate every 60 seconds
 
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
 
  if (!post) {
    notFound();
  }
 
  return (
    <article className="max-w-3xl mx-auto py-12 px-4">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="flex items-center gap-4 text-gray-500 mb-8">
        <time>{post.publishedAt}</time>
        <span>ยท</span>
        <span>{post.readTime} min read</span>
      </div>
      <div className="prose prose-lg" dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

On-Demand Revalidation

For content that changes unpredictably, trigger revalidation explicitly:

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from "next/cache";
import { NextRequest } from "next/server";
 
export async function POST(request: NextRequest) {
  const body = await request.json();
 
  // Verify the request is authorized
  if (request.headers.get("authorization") !== `Bearer ${process.env.REVALIDATION_SECRET}`) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  // Revalidate specific content by tag
  if (body.tag) {
    revalidateTag(body.tag);
    return Response.json({ revalidated: true, tag: body.tag });
  }
 
  // Revalidate an entire path
  if (body.path) {
    revalidatePath(body.path);
    return Response.json({ revalidated: true, path: body.path });
  }
 
  return Response.json({ error: "Provide 'tag' or 'path'" }, { status: 400 });
}

How Tags Connect to Data Fetching

Tags link your fetch calls to revalidateTag:

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug, {
    next: {
      tags: [`blog-${params.slug}`], // This tag links to revalidateTag
    },
  });
 
  return <Article post={post} />;
}

When revalidateTag("blog-my-post") is called, Next.js knows to invalidate all cached data fetched with that tag โ€” including the page that rendered it.

On-demand revalidation flow

Architecture and Design Patterns

Pattern 1: CMS Webhook Integration

Connect your headless CMS to trigger revalidation when content changes:

// app/api/webhook/strapi/route.ts
import { revalidateTag, revalidatePath } from "next/cache";
import crypto from "crypto";
 
function verifyStrapiSignature(body: string, signature: string): boolean {
  const expected = crypto
    .createHmac("sha256", process.env.STRAPI_WEBHOOK_SECRET!)
    .update(body)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
 
export async function POST(request: NextRequest) {
  const rawBody = await request.text();
  const signature = request.headers.get("x-strapi-signature") || "";
 
  if (!verifyStrapiSignature(rawBody, signature)) {
    return Response.json({ error: "Invalid signature" }, { status: 401 });
  }
 
  const { event, model, entry } = JSON.parse(rawBody);
 
  switch (model) {
    case "blog-post":
      revalidateTag(`blog-${entry.slug}`);
      revalidatePath("/blog");
      break;
    case "category":
      revalidateTag("categories");
      revalidatePath("/blog");
      break;
    case "author":
      revalidateTag(`author-${entry.id}`);
      // Revalidate all blog posts by this author
      const posts = await getPostsByAuthor(entry.id);
      posts.forEach(post => revalidateTag(`blog-${post.slug}`));
      break;
  }
 
  return Response.json({ revalidated: true, model, event });
}

Pattern 2: Multi-Layer ISR with Different Revalidation Periods

// app/products/[id]/page.tsx
import { Suspense } from "react";
 
// Product details: revalidate every 5 minutes
export const revalidate = 300;
 
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id, {
    next: { tags: [`product-${params.id}`], revalidate: 300 },
  });
 
  return (
    <div>
      <ProductDetails product={product} />
 
      {/* Price: revalidated more frequently via separate fetch */}
      <Suspense fallback={<PriceSkeleton />}>
        <DynamicPrice productId={params.id} />
      </Suspense>
 
      {/* Reviews: always fresh, not ISR-cached */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsSection productId={params.id} />
      </Suspense>
    </div>
  );
}
 
async function DynamicPrice({ productId }: { productId: string }) {
  // This component fetches fresh data on every request
  const price = await getPrice(productId, { cache: "no-store" });
  return (
    <div className="text-3xl font-bold text-green-600">
      ${price.current.toFixed(2)}
    </div>
  );
}

Pattern 3: ISR with Fallback for Unbuilt Pages

For sites with thousands of pages, you can't build them all at build time. ISR handles unbuilt pages gracefully:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  // Only pre-render the 100 most popular posts at build time
  const popularPosts = await getPopularPosts(100);
  return popularPosts.map(post => ({ slug: post.slug }));
}
 
export const revalidate = 3600;
 
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  if (!post) notFound();
 
  return <Article post={post} />;
}

When a user visits a post not in generateStaticParams:

  1. Next.js serves a fallback response (or renders on-demand)
  2. The page is generated and cached
  3. Subsequent visitors get the cached version

Step-by-Step Implementation

Step 1: Add ISR to a Blog

// app/blog/[slug]/page.tsx
import { getPosts, getPost } from "@/lib/cms";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
 
export async function generateStaticParams() {
  const posts = await getPosts({ limit: 50 });
  return posts.map(post => ({ slug: post.slug }));
}
 
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const post = await getPost(params.slug);
  if (!post) return {};
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [{ url: post.coverImage }] },
  };
}
 
export const revalidate = 3600;
 
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug, {
    next: { tags: [`blog-${params.slug}`] },
  });
 
  if (!post) notFound();
 
  return (
    <article className="max-w-3xl mx-auto py-12 px-4">
      <img src={post.coverImage} alt={post.title} className="w-full rounded-xl mb-8" />
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="prose prose-lg mt-8" dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Step 2: Set Up Webhook Handler

// app/api/webhook/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest } from "next/server";
 
export async function POST(request: NextRequest) {
  const body = await request.json();
 
  if (body.secret !== process.env.WEBHOOK_SECRET) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  const { type, slug } = body;
 
  if (type === "post.published" || type === "post.updated") {
    revalidateTag(`blog-${slug}`);
  }
 
  return Response.json({ revalidated: true });
}

Step 3: Add ISR to the Blog Listing Page

// app/blog/page.tsx
import { getPosts } from "@/lib/cms";
import Link from "next/link";
 
export const revalidate = 300; // 5 minutes
 
export default async function BlogListPage() {
  const posts = await getPosts({ limit: 20 });
 
  return (
    <div className="max-w-4xl mx-auto py-12 px-4">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      <div className="space-y-8">
        {posts.map(post => (
          <Link key={post.slug} href={`/blog/${post.slug}`} className="block group">
            <article className="border-b pb-8">
              <h2 className="text-2xl font-semibold group-hover:text-blue-600">{post.title}</h2>
              <p className="text-gray-600 mt-2">{post.excerpt}</p>
              <time className="text-sm text-gray-400 mt-2 block">{post.publishedAt}</time>
            </article>
          </Link>
        ))}
      </div>
    </div>
  );
}

Step 4: Test Revalidation

# Trigger on-demand revalidation
curl -X POST http://localhost:3000/api/webhook \
  -H "Content-Type: application/json" \
  -d '{"secret": "your-secret", "type": "post.updated", "slug": "my-post"}'
 
# Verify the page was revalidated
curl -I http://localhost:3000/blog/my-post
# Look for x-nextjs-cache: HIT (revalidated) or STALE (pending)

ISR testing workflow

Real-World Use Cases

Use Case 1: E-Commerce Product Pages

An online store with 50,000 products pre-renders the top 500 at build time. The remaining 49,500 are generated on first visit via ISR with a 5-minute revalidation period. When a product price changes, the admin panel calls revalidateTag("product-123") for instant updates. During peak traffic (Black Friday), the revalidation period is shortened to 30 seconds to ensure price accuracy.

Use Case 2: Documentation with Versioning

A documentation site with 1,000+ pages uses ISR with 1-hour revalidation. Each page is tagged with its version (docs-v2-api-reference) so version updates only invalidate the affected pages. The CI pipeline triggers on-demand revalidation when a documentation PR merges, ensuring users always see the latest version without full site rebuilds.

Use Case 3: Multi-Language Blog

A blog available in 10 languages pre-renders the English versions at build time. Other language versions are generated on first visit via ISR. When a post is updated, the webhook triggers revalidation for all language versions (revalidateTag("blog-my-post-en"), revalidateTag("blog-my-post-fr"), etc.).

Best Practices for Production

  1. Use longer revalidation periods for stable content: Blog posts and documentation rarely change โ€” 1 hour or more is appropriate. Shorter periods increase server load without meaningful benefit.

  2. Use on-demand revalidation for time-sensitive content: Prices, inventory, and breaking news should use webhook-triggered revalidateTag instead of time-based revalidation.

  3. Pre-render your most popular pages: Use generateStaticParams for your top 100-500 pages to ensure they're cached at build time. The long tail handles itself via ISR.

  4. Tag every data fetch: Use next: { tags: [...] } on all your fetch calls. Without tags, you can't do targeted on-demand revalidation.

  5. Handle revalidation failures gracefully: If a revalidation webhook fails, the stale content continues to serve โ€” which is the correct behavior. Don't retry aggressively.

  6. Use revalidatePath for listing pages: When a new post is published, revalidatePath("/blog") ensures the listing page updates without needing to tag every blog post.

  7. Monitor cache hit rates: Track x-nextjs-cache headers to understand your cache hit/miss ratio. Low hit rates indicate you need longer revalidation periods.

  8. Test with next build && next start: ISR in development mode doesn't cache โ€” always test with a production build to verify behavior.

Common Pitfalls and Solutions

PitfallImpactSolution
Too short revalidation periodServer overload during traffic spikesStart with 3600s, reduce only if content freshness demands it
Missing tags on fetch callsCan't do targeted revalidationAlways add next: { tags: [...] } to fetch calls
Not pre-rendering popular pagesAll pages generated on first visit (slow)Use generateStaticParams for top pages
Revalidation webhook not securedAnyone can invalidate your cacheVerify webhook signatures or use API keys
Testing in dev mode onlyISR doesn't cache in devTest with next build && next start
Assuming instant updatesFirst request after expiry serves stale contentDesign UIs that tolerate brief staleness

Performance Optimization

// Optimized ISR with parallel fetching
export const revalidate = 300;
 
export default async function ProductPage({ params }: { params: { id: string } }) {
  const [product, reviews, related] = await Promise.all([
    getProduct(params.id, { next: { tags: [`product-${params.id}`], revalidate: 300 } }),
    getReviews(params.id, { next: { tags: [`reviews-${params.id}`], revalidate: 60 } }),
    getRelatedProducts(params.id, { next: { revalidate: 3600 } }),
  ]);
 
  if (!product) notFound();
 
  return (
    <div className="grid grid-cols-3 gap-8">
      <div className="col-span-2">
        <ProductDetails product={product} />
        <ReviewsList reviews={reviews} />
      </div>
      <aside>
        <RelatedProducts products={related} />
      </aside>
    </div>
  );
}

Comparison with Alternatives

FeatureISRSSGSSRDSG*
Build timeShort (top pages only)Long (all pages)ShortShort
FreshnessConfigurableStale until rebuildAlways freshConfigurable
CDN cachingYesYesNoYes
Server costLowNoneHighLow
Long tail pagesHandled by ISRMust build allHandled by SSRHandled by DSG

*DSG = Deferred Static Generation (Gatsby's equivalent)

Advanced Patterns

Cascading Revalidation

When updating a category, invalidate all posts in that category:

async function revalidateCategory(categorySlug: string) {
  revalidateTag(`category-${categorySlug}`);
 
  const posts = await getPostsByCategory(categorySlug);
  for (const post of posts) {
    revalidateTag(`blog-${post.slug}`);
  }
}

Conditional Revalidation

Only revalidate if the content actually changed:

export async function POST(request: NextRequest) {
  const body = await request.json();
  const currentHash = await getContentHash(body.slug);
 
  if (currentHash === body.hash) {
    return Response.json({ revalidated: false, reason: "Content unchanged" });
  }
 
  revalidateTag(`blog-${body.slug}`);
  return Response.json({ revalidated: true });
}

Testing Strategies

describe("ISR Behavior", () => {
  it("serves cached page within revalidation period", async () => {
    const res1 = await fetch("http://localhost:3000/blog/test-post");
    const res2 = await fetch("http://localhost:3000/blog/test-post");
 
    expect(res1.headers.get("x-nextjs-cache")).toBe("HIT");
    expect(res2.headers.get("x-nextjs-cache")).toBe("HIT");
  });
 
  it("revalidates on-demand", async () => {
    // Trigger revalidation
    await fetch("http://localhost:3000/api/webhook", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: "test", type: "post.updated", slug: "test-post" }),
    });
 
    // Next request should trigger regeneration
    const res = await fetch("http://localhost:3000/blog/test-post");
    expect(res.status).toBe(200);
  });
});

Future Outlook

ISR is the foundation for Next.js's evolving caching strategy. Cache Components and Partial Prerendering build on ISR's concepts, extending them to the component level. The revalidateTag API is becoming the universal cache invalidation primitive across Next.js, and future versions may support tag dependencies (invalidating blog-post also invalidates blog-list) and cache warming (pre-generating pages before they're requested).

Production Deployment and Monitoring

Deploying React applications to production requires careful consideration of build optimization, error tracking, and performance monitoring. A well-configured production build can significantly improve user experience through faster load times and more reliable error reporting.

Build Optimization Checklist

Before deploying, verify that your production build is fully optimized:

// next.config.js
module.exports = {
  reactStrictMode: true,
  poweredByHeader: false,
  compress: true,
 
  // Optimize images
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
  },
 
  // Security headers
  async headers() {
    return [{
      source: '/(.*)',
      headers: [
        { key: 'X-Frame-Options', value: 'DENY' },
        { key: 'X-Content-Type-Options', value: 'nosniff' },
        { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      ],
    }];
  },
 
  // Webpack optimization
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendor',
            chunks: 'all',
          },
        },
      };
    }
    return config;
  },
};

Error Tracking Integration

Configure Sentry or a similar error tracking service to capture and categorize production errors:

import * as Sentry from '@sentry/nextjs';
 
Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,
  integrations: [
    new Sentry.BrowserTracing(),
    new Sentry.Replay({
      maskAllText: true,
      blockAllMedia: true,
    }),
  ],
  beforeSend(event) {
    // Filter out known non-critical errors
    if (event.exception?.values?.[0]?.type === 'ChunkLoadError') {
      return null;
    }
    return event;
  },
});

Health Check Endpoints

Implement health check endpoints that your load balancer and monitoring systems can use to verify application availability:

// pages/api/health.ts
export default async function handler(req, res) {
  try {
    // Check database connectivity
    await db.raw('SELECT 1');
 
    // Check external service dependencies
    const redisPing = await redis.ping();
 
    res.status(200).json({
      status: 'healthy',
      timestamp: new Date().toISOString(),
      services: {
        database: 'connected',
        redis: redisPing === 'PONG' ? 'connected' : 'degraded',
      },
      uptime: process.uptime(),
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error.message,
    });
  }
}

This comprehensive monitoring approach ensures you detect and respond to production issues quickly, maintaining high availability for your users.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

ISR is the sweet spot between static generation and server-side rendering. It delivers CDN-level performance with configurable content freshness, making it ideal for content-heavy sites that need both speed and freshness.

Key takeaways:

  1. Use time-based revalidation for content that changes predictably โ€” 1 hour is a good starting point.
  2. Use on-demand revalidation for content that changes unpredictably โ€” connect webhooks to revalidateTag.
  3. Pre-render popular pages with generateStaticParams โ€” ISR handles the long tail automatically.
  4. Tag every data fetch so you can do targeted invalidation when content changes.
  5. Test with production builds โ€” ISR doesn't cache in development mode.

Start by adding export const revalidate = 3600 to your content pages and connecting your CMS webhooks for on-demand updates. The performance and freshness benefits are immediate.

For more details, see the Next.js ISR documentation.