Skip to main content
business5 min read

ISR, On-Demand Revalidation, and 10K Product Pages: Our Next.js Scaling Playbook

How do you keep 10,000+ product comparison pages fresh without rebuilding your entire site? This was...

A Versus B Editorial Team
Updated

How do you keep 10,000+ product comparison pages fresh without rebuilding your entire site?

This was the core infrastructure challenge we faced at SmartReview. Product specs change. Prices fluctuate. New reviews come in daily. Static generation doesn't cut it, and full SSR at this scale would crush our server budget.

The answer: Next.js Incremental Static Regeneration (ISR) with on-demand revalidation. Here's our playbook.

The Scale Problem#

Our site has:

  • 10,000+ comparison pages (e.g., AirPods Pro vs Sony WF-1000XM5)
  • 5,000+ product detail pages
  • 500+ category pages
  • New content generated daily via our AI pipeline

Full static builds at this scale take 45+ minutes. That's unacceptable when a product's price drops or a major review site publishes a new rating.

Our ISR Strategy#

We use three tiers of revalidation based on content freshness requirements:

Tier 1: Hot Pages (revalidate: 3600)#

Top 500 comparison pages by traffic. These cover products actively being purchased u2014 AirPods, Roomba, Nespresso. One-hour revalidation ensures prices and ratings stay current.

// app/compare/[slug]/page.tsx

export async function generateStaticParams() {

// Only pre-build top 500 pages at deploy time

const hotSlugs = await getTopComparisonsByTraffic(500);

return hotSlugs.map(slug => ({ slug }));

}

export const revalidate = 3600; // 1 hour for hot pages

Tier 2: Warm Pages (revalidate: 86400)#

The next 3,000 pages. Popular enough to warrant daily freshness, but not critical enough for hourly updates.

Tier 3: Long Tail (revalidate: 604800)#

The remaining 7,000+ pages. These are niche comparisons with lower traffic. Weekly revalidation is sufficient u2014 and if data changes significantly, we trigger on-demand revalidation.

On-Demand Revalidation: The Secret Weapon#

ISR's time-based revalidation is a blunt instrument. The real power comes from on-demand revalidation u2014 triggering a rebuild for specific pages when their underlying data changes.

We built a webhook system that fires revalidation when:

// app/api/revalidate/route.ts

import { revalidatePath } from "next/cache";

export async function POST(request: Request) {

const { secret, paths, reason } = await request.json();

if (secret !== process.env.REVALIDATION_SECRET) {

return Response.json({ error: "Unauthorized" }, { status: 401 });

}

const revalidated: string[] = [];

for (const path of paths) {

revalidatePath(path);

revalidated.push(path);

}

console.log(`Revalidated ${revalidated.length} paths. Reason: ${reason}`);

return Response.json({ revalidated, timestamp: Date.now() });

}

Triggers for on-demand revalidation:#

  1. Price change detected u2014 our price monitoring pipeline checks retailer APIs every 4 hours
  2. New review ingested u2014 when our review aggregation pipeline processes a new batch from Reddit, Amazon, or RTINGS
  3. Product launch/update u2014 manufacturer announces a new model or firmware update
  4. Manual override u2014 editorial team flags a page as needing immediate refresh
// services/price-monitor.ts

async function onPriceChange(productSlug: string, newPrice: number) {

// Update database

await db.product.update({

where: { slug: productSlug },

data: { currentPrice: newPrice },

});

// Find all comparison pages featuring this product

const affectedComparisons = await db.comparison.findMany({

where: {

OR: [

{ entityASlug: productSlug },

{ entityBSlug: productSlug },

],

},

});

// Trigger revalidation for each affected page

const paths = affectedComparisons.map(c => `/compare/${c.slug}`);

paths.push(`/product/${productSlug}`);

await fetch(`${process.env.SITE_URL}/api/revalidate`, {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify({

secret: process.env.REVALIDATION_SECRET,

paths,

reason: `Price change for ${productSlug}: $${newPrice}`,

}),

});

}

Handling the Cold Start Problem#

With 10,000+ pages but only 500 pre-built, most pages are generated on first request. The cold start experience matters:

// app/compare/[slug]/loading.tsx

export default function ComparisonLoading() {

return (

<div className="comparison-skeleton">

<div className="skeleton-header" />

<div className="skeleton-table">

{Array.from({ length: 8 }).map((_, i) => (

<div key={i} className="skeleton-row" />

))}

</div>

</div>

);

}

We also implemented a warming strategy: after generating new comparison content via our AI pipeline, we immediately hit the page URL to trigger ISR generation before any real user sees it.

// After content generation

async function warmPage(slug: string) {

try {

await fetch(`${process.env.SITE_URL}/compare/${slug}`, {

headers: { "Purpose": "prefetch" },

});

} catch {

// Non-critical u2014 page will generate on first real visit

}

}

Caching Beyond ISR#

ISR handles page-level caching, but we also cache at the data layer:

Redis for hot data:

  • Product specs and prices: 4-hour TTL
  • Review aggregations: 24-hour TTL
  • Trust scores: 24-hour TTL

Database query caching:

  • Comparison data: cached in Next.js `unstable_cache` with tags
  • Tag-based invalidation when underlying data changes
import { unstable_cache } from "next/cache";

const getComparison = unstable_cache(

async (slug: string) => {

return db.comparison.findUnique({

where: { slug },

include: { entityA: true, entityB: true, attributes: true },

});

},

["comparison"],

{ tags: ["comparisons"], revalidate: 3600 }

);

Performance Results#

MetricBefore ISRAfter ISR
Build time47 min4 min (500 pages)
TTFB (hot pages)180ms SSR12ms cached
TTFB (cold pages)180ms SSR800ms first, 12ms after
Data freshnessDeploy-time1-24 hours (tier-based)
Server cost$340/mo$89/mo

The 74% reduction in server costs alone justified the migration. But the real win is data freshness without deploy overhead u2014 prices update within hours, not days.

Lessons Learned#

  1. Don't pre-build everything. Only pre-build your top pages. Let ISR handle the long tail.
  2. On-demand > time-based. Time-based revalidation is a fallback. Event-driven revalidation is the goal.
  3. Warm your pages. Don't let real users hit cold starts. Pre-fetch after content generation.
  4. Cache in layers. ISR for pages, Redis for data, `unstable_cache` for queries. Each layer serves a different freshness requirement.
  5. Monitor revalidation. Log every revalidation with its reason. You'll need this for debugging stale content.

What's Next#

We're experimenting with Next.js Partial Prerendering (PPR) to serve static shells instantly while streaming dynamic comparison data. Early results show sub-100ms TTFB for all pages regardless of cache state.

Check out the live implementation at aversusb.net.

---

Part 6 of our "Building SmartReview" series. Previous: Part 5: Building a Product Trust Score

#nextjs#webdev#javascript#performance

Share this article

Share:

Get the best comparisons in your inbox

Weekly digest of trending comparisons, new categories, and expert insights. No spam.

Join 1,000+ readers. Unsubscribe anytime.