DEV Community

Daniel Rozin
Daniel Rozin

Posted on • Originally published at aversusb.net

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 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 — 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
Enter fullscreen mode Exit fullscreen mode

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 — 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 — 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() });
}
Enter fullscreen mode Exit fullscreen mode

Triggers for on-demand revalidation:

  1. Price change detected — our price monitoring pipeline checks retailer APIs every 4 hours
  2. New review ingested — when our review aggregation pipeline processes a new batch from Reddit, Amazon, or RTINGS
  3. Product launch/update — manufacturer announces a new model or firmware update
  4. Manual override — 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}`,
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 — page will generate on first real visit
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

Performance Results

Metric Before ISR After ISR
Build time 47 min 4 min (500 pages)
TTFB (hot pages) 180ms SSR 12ms cached
TTFB (cold pages) 180ms SSR 800ms first, 12ms after
Data freshness Deploy-time 1-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 — 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

Top comments (0)