ISR at Scale: Running 25,000+ Pages with Incremental Static Regeneration on Vercel
Last year we shipped a Next.js site with over 25,000 statically generated pages on Vercel. Product pages, blog posts, location landing pages, dynamic category filters -- the whole nine yards. The promise of Incremental Static Regeneration is seductive: get the speed of static sites with the freshness of server-rendered content. And honestly? It mostly delivers. But at 25,000+ pages, ISR behaves differently than it does on your 50-page marketing site. The edge cases become your main cases. The costs creep up. The cache invalidation problems that seemed theoretical in the docs become very, very real.
This is the article I wish existed before we started. Everything here comes from production experience -- real metrics, real billing surprises, and real architectural decisions we made (and sometimes regretted).

Table of Contents
- What ISR Actually Does Under the Hood
- Why 25,000 Pages Changes Everything
- Build Strategy: What to Pre-render vs. Defer
- Revalidation Patterns That Actually Work
- Vercel-Specific Gotchas and Limits
- Real Production Costs at Scale
- Monitoring and Debugging ISR in Production
- Architecture Decisions: ISR vs. Alternatives
- Performance Benchmarks From Our Deployment
- FAQ
What ISR Actually Does Under the Hood
Before we get into scale problems, let's make sure we're on the same page about what ISR is doing. When you set revalidate: 60 in a Next.js page, here's the actual flow:
First request after deploy: If the page was pre-rendered at build time, Vercel serves it from the edge cache. If not (you returned
fallback: 'blocking'or useddynamicParams: truein App Router), it renders server-side, caches the result, then serves it.Subsequent requests within the revalidation window: Served from cache. Fast. No compute.
First request after revalidation window expires: The stale page is served immediately (this is the "stale-while-revalidate" part), and a background regeneration is triggered. The next visitor gets the fresh page.
This is conceptually simple. But at 25,000 pages, that background regeneration step becomes a firehose.
// App Router (Next.js 14/15)
export const revalidate = 60; // seconds
export async function generateStaticParams() {
// At 25k pages, you probably don't want to return all of them here
const topPages = await getTop500Pages();
return topPages.map((page) => ({ slug: page.slug }));
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug);
return <ProductTemplate product={product} />;
}
The Stale-While-Revalidate Tradeoff
The thing that trips people up: ISR always serves stale content to the request that triggers regeneration. This is a feature, not a bug -- it means no visitor ever waits for a render. But it also means your content is always at least one request behind. For a 25,000-page site where some pages get visited once a week, that "one request behind" might mean someone sees content that's days old after the revalidation window passed, because nobody visited to trigger the regeneration.
Why 25,000 Pages Changes Everything
At small scale, ISR is basically magic. At large scale, three things change:
Build Times Become a Bottleneck
If you try to pre-render all 25,000 pages at build time, you're looking at build times that'll make you question your life choices. Each page needs to fetch its data, render React to HTML, and generate the static assets. Even at 200ms per page (which is optimistic if you're hitting a CMS API), that's 5,000 seconds -- over 83 minutes. Vercel's Pro plan has a build timeout of 45 minutes. Enterprise gets more, but you're still burning compute credits.
Cache Invalidation Becomes a Real Problem
With 25,000 pages, you can't just "rebuild everything" when content changes. You need surgical invalidation. Vercel's revalidatePath() and revalidateTag() APIs help, but they have their own quirks at scale that we'll cover.
Background Regeneration Load Spikes
Imagine 5,000 pages all have revalidate: 60 and they all get traffic simultaneously. That's 5,000 serverless function invocations happening in the background every minute. Your CMS API better be able to handle that.

Build Strategy: What to Pre-render vs. Defer
This is the single most important architectural decision for large ISR sites. Here's the framework we use:
| Page Category | Count (Our Case) | Strategy | Reasoning |
|---|---|---|---|
| High-traffic pages (top 500) | 500 | Pre-render at build | These get hit immediately after deploy. No cold-start penalty. |
| Medium-traffic pages | 4,500 | Defer with fallback: 'blocking' |
First visitor waits ~300ms, then it's cached. Acceptable. |
| Long-tail pages | 20,000 | Defer with fallback: 'blocking' |
Most won't be visited for hours/days after deploy. No point pre-rendering. |
The key insight: don't pre-render pages nobody's going to visit in the first hour after deploy. You're wasting build minutes and money.
// generateStaticParams - only return your high-traffic pages
export async function generateStaticParams() {
// We use analytics data to determine the top pages
const topPages = await fetch('https://api.example.com/pages/top?limit=500', {
headers: { Authorization: `Bearer ${process.env.CMS_TOKEN}` },
}).then(r => r.json());
return topPages.map((page: { slug: string }) => ({
slug: page.slug,
}));
}
With this approach, our builds went from timing out to completing in about 8 minutes. That's a massive difference. We wrote about similar optimization strategies in the context of our Next.js development work -- the principles apply broadly.
The `dynamicParams` Setting Matters
In App Router, setting dynamicParams = true (the default) means pages not returned by generateStaticParams will be rendered on-demand and cached. Setting it to false returns a 404 for any page not pre-rendered. For a 25,000-page site, you almost certainly want true.
export const dynamicParams = true; // Allow on-demand rendering for pages not in generateStaticParams
Revalidation Patterns That Actually Work
Time-Based Revalidation
The simplest approach. Set revalidate to a number of seconds. But what number?
Here's what we landed on after months of tuning:
| Content Type | Revalidation Period | Why |
|---|---|---|
| Product prices | 60 seconds | Prices change frequently, customers notice stale prices |
| Product descriptions | 3600 seconds (1 hour) | Rarely changes, not time-sensitive |
| Blog posts | 86400 seconds (24 hours) | Almost never changes after publish |
| Category/listing pages | 300 seconds (5 minutes) | New products appear, but slight delay is OK |
| Location pages | 86400 seconds (24 hours) | Address info barely changes |
The mistake we made early on: setting everything to 60 seconds. This hammered our CMS (Contentful, in our case) API with regeneration requests and we hit rate limits during traffic spikes.
On-Demand Revalidation
This is the better approach for most content updates. Instead of polling with time-based revalidation, you trigger regeneration when content actually changes:
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidation-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
const body = await request.json();
// Tag-based revalidation -- this is the way
if (body.tag) {
revalidateTag(body.tag);
return NextResponse.json({ revalidated: true, tag: body.tag });
}
// Path-based revalidation as fallback
if (body.path) {
revalidatePath(body.path);
return NextResponse.json({ revalidated: true, path: body.path });
}
return NextResponse.json({ error: 'No tag or path provided' }, { status: 400 });
}
Then set up a webhook in your CMS to hit this endpoint whenever content is published. We pair this with a longer time-based revalidation (like 24 hours) as a safety net.
Tag-Based Revalidation at Scale
This is where Next.js 14+ really shines for large sites. You can tag your fetch requests and invalidate by tag:
async function getProduct(slug: string) {
const res = await fetch(`https://api.cms.com/products/${slug}`, {
next: {
tags: [`product-${slug}`, 'products', 'all-content'],
revalidate: 86400 // 24 hour safety net
},
});
return res.json();
}
Now when a single product is updated, you call revalidateTag('product-blue-widget') and only that page regenerates. When you do a bulk price update, call revalidateTag('products') and all product pages regenerate on their next visit.
The gotcha: calling revalidateTag('products') on a site with 25,000 product pages doesn't regenerate them all immediately. It marks them all as stale. They regenerate on next visit. This is important -- it means some pages might not actually update for days if they have low traffic.
Vercel-Specific Gotchas and Limits
We've been running this on Vercel since early 2024. Here are the things the docs don't emphasize enough:
ISR Cache Storage
Vercel stores ISR pages in their Edge Network cache. As of 2025, the Vercel Data Cache has some limits you should know about:
- Pro plan: Included ISR cache is generous, but there's a cost for cache reads/writes at very high volume
- Enterprise: Custom limits, but you're paying for it
- Cache entries don't live forever: Even with
revalidate: false, Vercel can evict cache entries that haven't been accessed recently. We've seen pages disappear from cache after about 30 days of no traffic on the Pro plan.
Serverless Function Duration
Background regeneration runs as a serverless function. On Vercel Pro, the default timeout is 60 seconds (you can configure up to 300 seconds). If your page takes longer than that to regenerate -- say, because your CMS is slow or you're doing heavy image processing -- the regeneration fails silently and the stale page keeps being served.
We hit this with pages that fetched data from three different APIs. The fix was to add a caching layer (Redis via Upstash) between our Next.js app and the slowest API.
Concurrent Regeneration Limits
Vercel doesn't publish hard numbers on this, but we observed throttling when more than ~1,000 ISR regenerations were triggered simultaneously (e.g., after calling revalidateTag on a widely-used tag). The regenerations queue up and process over several minutes rather than all at once. Plan for this.
Cold Starts
Pages that haven't been visited in a while (and have been evicted from edge cache) will experience a cold start on next visit. In our benchmarks:
- Warm cache hit: 15-40ms TTFB
- Stale revalidation (served from cache): 15-40ms TTFB (same, since stale is served)
- Cold regeneration (no cache, blocking): 400-1200ms TTFB depending on API response times
Real Production Costs at Scale
Let's talk money. This is where people get surprised.
Our 25,000-page site on Vercel Pro ($20/month base) with ISR:
| Cost Component | Monthly | Notes |
|---|---|---|
| Vercel Pro subscription | $20 | Base plan |
| Serverless Function Execution | $180-$340 | Varies with traffic. ISR regenerations count as function invocations. |
| Edge Bandwidth | $90-$150 | 25k pages with images adds up |
| Vercel Data Cache | $40-$80 | Cache reads/writes for ISR |
| Total Vercel | $330-$590/mo | Depends on traffic month |
| Contentful (CMS) | $489/mo | Their Team plan. API calls from ISR regeneration pushed us over the free tier fast. |
| Upstash Redis (caching) | $30/mo | Added to reduce CMS API calls |
| Grand Total | $849-$1,109/mo | For a site serving ~2M pageviews/month |
Is this expensive? Compared to a traditional server setup, it's competitive. Compared to a static site on a CDN, it's pricey. The ISR regeneration function invocations are the biggest variable cost -- every time a page regenerates, that's a serverless function running for 1-5 seconds.
We've worked with clients who've explored Astro-based approaches for content-heavy sites where ISR's costs start to outweigh its benefits. For sites where content changes infrequently, a full static build with Astro can be significantly cheaper to host.
Monitoring and Debugging ISR in Production
ISR failures are silent by default. The stale page keeps being served, and you might not know your regeneration has been failing for days. Here's our monitoring setup:
Custom Regeneration Logging
// lib/with-regeneration-logging.ts
export async function fetchWithLogging(
url: string,
options: RequestInit & { next?: { tags?: string[]; revalidate?: number } }
) {
const start = Date.now();
try {
const res = await fetch(url, options);
const duration = Date.now() - start;
// Log to your monitoring service
if (duration > 5000) {
console.warn(`[ISR] Slow fetch: ${url} took ${duration}ms`);
// Send to Datadog/Sentry/etc.
}
return res;
} catch (error) {
console.error(`[ISR] Fetch failed: ${url}`, error);
// This is critical -- if fetch fails, regeneration fails
throw error;
}
}
Vercel's Built-in Tools
Vercel's dashboard shows ISR cache hit rates and regeneration counts. In the Analytics tab, look for:
- Cache status in the function logs:
HIT,MISS,STALE - ISR regeneration duration in the serverless function metrics
- Error rates on your ISR routes
The `x-vercel-cache` Header
Every response from Vercel includes this header:
HIT-- Served from edge cache, freshSTALE-- Served from edge cache, regeneration triggered in backgroundMISS-- Not in cache, rendered on-demand
We set up a simple monitor that checks 100 random pages every hour and alerts if more than 10% return MISS -- that would indicate cache eviction problems.
Architecture Decisions: ISR vs. Alternatives
After running ISR at this scale for over a year, here's my honest take on when to use it and when not to:
Use ISR When:
- You have 5,000-100,000 pages that change at different frequencies
- Content freshness measured in minutes (not seconds) is acceptable
- You're already committed to Next.js
- Your team understands cache invalidation (it's not optional knowledge at this scale)
Consider Alternatives When:
- You need real-time content (use SSR or client-side fetching instead)
- Your site rarely changes (full static builds are simpler and cheaper)
- You have 500,000+ pages (ISR starts to strain at very high page counts -- consider a distributed build approach)
- Cost is the primary concern (self-hosted Next.js with your own CDN can be 60-70% cheaper)
For clients with complex content architectures, we often recommend a headless CMS setup that gives you flexibility to switch between ISR, SSR, and full static depending on the content type.
The Hybrid Approach We Actually Use
We don't use ISR for everything on our 25k-page site. Here's the breakdown:
- ISR: Product pages, category pages, location pages (22,000 pages)
- SSR: Search results, user dashboard, cart
- Static: About, contact, legal pages (generated at build time, no revalidation)
- Client-side: Real-time inventory counts, user-specific pricing
This hybrid approach reduced our serverless function costs by about 40% compared to our initial "ISR everything" strategy.
Performance Benchmarks From Our Deployment
Here are real numbers from our production deployment, measured over Q1 2025:
| Metric | ISR Cache Hit | ISR Cache Miss (Blocking) | Full SSR (No Cache) |
|---|---|---|---|
| TTFB (p50) | 22ms | 480ms | 620ms |
| TTFB (p95) | 58ms | 1,100ms | 1,450ms |
| TTFB (p99) | 120ms | 2,800ms | 3,200ms |
| LCP (p50) | 1.1s | 1.8s | 2.2s |
| CLS | 0.02 | 0.02 | 0.05 |
| Core Web Vitals Pass Rate | 96% | 78% | 64% |
The difference between a cache hit and a miss is dramatic. This is why your pre-rendering strategy matters so much -- you want your high-traffic pages to always be warm.
One interesting finding: our Core Web Vitals scores improved by 12% when we moved from revalidate: 60 to revalidate: 3600 on low-change content. Fewer regenerations meant more consistent cache hits, which meant more consistent performance.
FAQ
How many pages can ISR handle on Vercel before performance degrades?
We've run 25,000 pages without significant issues, and I've heard of deployments with 100,000+ pages working fine. The bottleneck isn't the number of pages in cache -- it's the rate of simultaneous regenerations. If you have 50,000 pages all with revalidate: 60, you'll run into problems. Spread your revalidation periods based on content change frequency and you'll be fine.
Does ISR cost more than SSR on Vercel?
Generally, ISR is significantly cheaper than SSR for the same traffic volume. With ISR, most requests are served from edge cache (essentially free compute). With SSR, every request runs a serverless function. For our 2M pageviews/month site, ISR's function invocations (from regenerations) were roughly 15% of what full SSR would have been.
What happens when ISR regeneration fails?
The stale version continues to be served. This is both a feature and a risk. Your users don't see errors, but they might see outdated content. We've had situations where a CMS API outage meant pages were serving content that was 6 hours old before anyone noticed. Set up monitoring.
Can I use ISR with the Next.js App Router?
Yes, and it's actually cleaner in App Router. You use export const revalidate = 60 at the page or layout level, and next: { revalidate, tags } in your fetch calls. The generateStaticParams function replaces getStaticPaths. Everything we've described in this article works with both Pages Router and App Router, though the App Router syntax is what we'd recommend for new projects in 2025.
How do I handle ISR with dynamic query parameters?
ISR only caches based on the URL path, not query parameters. If you need different cached versions for ?color=red vs ?color=blue, you need to use actual path segments (/product/widget/red instead of /product/widget?color=red) or handle the variation client-side. This caught us off guard with our filtering implementation.
Is on-demand revalidation reliable at scale?
Mostly. We've seen occasional delays of 10-30 seconds between calling revalidateTag() and the cache actually being invalidated across all edge locations. For 99% of use cases this is fine. If you need instant global invalidation, you might need to add cache-busting query params or use SSR for those specific pages.
Should I self-host Next.js instead of using Vercel for large ISR sites?
It depends on your team. Self-hosting (on AWS, for example) gives you more control over caching behavior and can be 50-70% cheaper at scale. But you're responsible for setting up the CDN cache invalidation, managing the build pipeline, and handling edge distribution yourself. We've seen teams spend months replicating what Vercel gives you out of the box. If you want to explore options, reach out to us -- we've done both.
What's the best CMS for a 25,000+ page ISR site?
We've used Contentful, Sanity, and Hygraph at this scale. Contentful handles webhook-based revalidation well but rate limits can be an issue (plan for caching). Sanity's GROQ subscriptions are great for real-time awareness of content changes. Hygraph's webhook system is solid. The key requirement is reliable webhook delivery and an API that can handle burst traffic from regeneration storms. Check our headless CMS development capabilities for more specific recommendations based on your content model.