{"slug":"nextjs-isr-scaling-playbook","title":"ISR, On-Demand Revalidation, and 10K Product Pages: Our Next.js Scaling Playbook","excerpt":"How do you keep 10,000+ product comparison pages fresh without rebuilding your entire site?  This was...","content":"How do you keep 10,000+ product comparison pages fresh without rebuilding your entire site?\n\nThis was the core infrastructure challenge we faced at [SmartReview](https://www.aversusb.net). 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.\n\nThe answer: Next.js Incremental Static Regeneration (ISR) with on-demand revalidation. Here's our playbook.\n\n## The Scale Problem\n\nOur site has:\n- **10,000+ comparison pages** (e.g., AirPods Pro vs Sony WF-1000XM5)\n- **5,000+ product detail pages**\n- **500+ category pages**\n- New content generated daily via our AI pipeline\n\nFull 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.\n\n## Our ISR Strategy\n\nWe use three tiers of revalidation based on content freshness requirements:\n\n### Tier 1: Hot Pages (revalidate: 3600)\nTop 500 comparison pages by traffic. These cover products actively being purchased u2014 AirPods, Roomba, Nespresso. One-hour revalidation ensures prices and ratings stay current.\n\n```typescript\n// app/compare/[slug]/page.tsx\nexport async function generateStaticParams() {\n  // Only pre-build top 500 pages at deploy time\n  const hotSlugs = await getTopComparisonsByTraffic(500);\n  return hotSlugs.map(slug => ({ slug }));\n}\n\nexport const revalidate = 3600; // 1 hour for hot pages\n```\n\n### Tier 2: Warm Pages (revalidate: 86400)\nThe next 3,000 pages. Popular enough to warrant daily freshness, but not critical enough for hourly updates.\n\n### Tier 3: Long Tail (revalidate: 604800)\nThe 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.\n\n## On-Demand Revalidation: The Secret Weapon\n\nISR'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.\n\nWe built a webhook system that fires revalidation when:\n\n```typescript\n// app/api/revalidate/route.ts\nimport { revalidatePath } from \"next/cache\";\n\nexport async function POST(request: Request) {\n  const { secret, paths, reason } = await request.json();\n  \n  if (secret !== process.env.REVALIDATION_SECRET) {\n    return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  const revalidated: string[] = [];\n  \n  for (const path of paths) {\n    revalidatePath(path);\n    revalidated.push(path);\n  }\n\n  console.log(`Revalidated ${revalidated.length} paths. Reason: ${reason}`);\n  return Response.json({ revalidated, timestamp: Date.now() });\n}\n```\n\n### Triggers for on-demand revalidation:\n\n1. **Price change detected** u2014 our price monitoring pipeline checks retailer APIs every 4 hours\n2. **New review ingested** u2014 when our review aggregation pipeline processes a new batch from Reddit, Amazon, or RTINGS\n3. **Product launch/update** u2014 manufacturer announces a new model or firmware update\n4. **Manual override** u2014 editorial team flags a page as needing immediate refresh\n\n```typescript\n// services/price-monitor.ts\nasync function onPriceChange(productSlug: string, newPrice: number) {\n  // Update database\n  await db.product.update({\n    where: { slug: productSlug },\n    data: { currentPrice: newPrice },\n  });\n\n  // Find all comparison pages featuring this product\n  const affectedComparisons = await db.comparison.findMany({\n    where: {\n      OR: [\n        { entityASlug: productSlug },\n        { entityBSlug: productSlug },\n      ],\n    },\n  });\n\n  // Trigger revalidation for each affected page\n  const paths = affectedComparisons.map(c => `/compare/${c.slug}`);\n  paths.push(`/product/${productSlug}`);\n\n  await fetch(`${process.env.SITE_URL}/api/revalidate`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      secret: process.env.REVALIDATION_SECRET,\n      paths,\n      reason: `Price change for ${productSlug}: $${newPrice}`,\n    }),\n  });\n}\n```\n\n## Handling the Cold Start Problem\n\nWith 10,000+ pages but only 500 pre-built, most pages are generated on first request. The cold start experience matters:\n\n```typescript\n// app/compare/[slug]/loading.tsx\nexport default function ComparisonLoading() {\n  return (\n    <div className=\"comparison-skeleton\">\n      <div className=\"skeleton-header\" />\n      <div className=\"skeleton-table\">\n        {Array.from({ length: 8 }).map((_, i) => (\n          <div key={i} className=\"skeleton-row\" />\n        ))}\n      </div>\n    </div>\n  );\n}\n```\n\nWe 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.\n\n```typescript\n// After content generation\nasync function warmPage(slug: string) {\n  try {\n    await fetch(`${process.env.SITE_URL}/compare/${slug}`, {\n      headers: { \"Purpose\": \"prefetch\" },\n    });\n  } catch {\n    // Non-critical u2014 page will generate on first real visit\n  }\n}\n```\n\n## Caching Beyond ISR\n\nISR handles page-level caching, but we also cache at the data layer:\n\n**Redis for hot data:**\n- Product specs and prices: 4-hour TTL\n- Review aggregations: 24-hour TTL\n- Trust scores: 24-hour TTL\n\n**Database query caching:**\n- Comparison data: cached in Next.js `unstable_cache` with tags\n- Tag-based invalidation when underlying data changes\n\n```typescript\nimport { unstable_cache } from \"next/cache\";\n\nconst getComparison = unstable_cache(\n  async (slug: string) => {\n    return db.comparison.findUnique({\n      where: { slug },\n      include: { entityA: true, entityB: true, attributes: true },\n    });\n  },\n  [\"comparison\"],\n  { tags: [\"comparisons\"], revalidate: 3600 }\n);\n```\n\n## Performance Results\n\n| Metric | Before ISR | After ISR |\n|--------|-----------|----------|\n| Build time | 47 min | 4 min (500 pages) |\n| TTFB (hot pages) | 180ms SSR | 12ms cached |\n| TTFB (cold pages) | 180ms SSR | 800ms first, 12ms after |\n| Data freshness | Deploy-time | 1-24 hours (tier-based) |\n| Server cost | $340/mo | $89/mo |\n\nThe 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.\n\n## Lessons Learned\n\n1. **Don't pre-build everything.** Only pre-build your top pages. Let ISR handle the long tail.\n2. **On-demand > time-based.** Time-based revalidation is a fallback. Event-driven revalidation is the goal.\n3. **Warm your pages.** Don't let real users hit cold starts. Pre-fetch after content generation.\n4. **Cache in layers.** ISR for pages, Redis for data, `unstable_cache` for queries. Each layer serves a different freshness requirement.\n5. **Monitor revalidation.** Log every revalidation with its reason. You'll need this for debugging stale content.\n\n## What's Next\n\nWe'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.\n\nCheck out the live implementation at [aversusb.net](https://www.aversusb.net).\n\n---\n\n*Part 6 of our \"Building SmartReview\" series. Previous: [Part 5: Building a Product Trust Score](https://dev.to/danie_rozin/building-a-product-trust-score-from-50-review-sources-3cpi)*","category":"business","tags":["nextjs","webdev","javascript","performance"],"url":"https://www.aversusb.net/blog/nextjs-isr-scaling-playbook","publishedAt":"2026-06-25T11:21:34.944Z","updatedAt":"2026-06-25T11:21:34.944Z","articleSchema":{"@context":"https://schema.org","@type":"BlogPosting","@id":"https://www.aversusb.net/blog/nextjs-isr-scaling-playbook#article","headline":"ISR, On-Demand Revalidation, and 10K Product Pages: Our Next.js Scaling Playbook","description":"How do you keep 10,000+ product comparison pages fresh without rebuilding your entire site?  This was...","abstract":"How do you keep 10,000+ product comparison pages fresh without rebuilding your entire site?  This was...","url":"https://www.aversusb.net/blog/nextjs-isr-scaling-playbook","image":{"@type":"ImageObject","@id":"https://www.aversusb.net/blog/nextjs-isr-scaling-playbook#primaryImage","url":"https://www.aversusb.net/api/og?title=ISR%2C%20On-Demand%20Revalidation%2C%20and%2010K%20Product%20Pages%3A%20Our%20Next.js%20Scaling%20Playbook&type=blog","contentUrl":"https://www.aversusb.net/api/og?title=ISR%2C%20On-Demand%20Revalidation%2C%20and%2010K%20Product%20Pages%3A%20Our%20Next.js%20Scaling%20Playbook&type=blog","width":1200,"height":630,"caption":"ISR, On-Demand Revalidation, and 10K Product Pages: Our Next.js Scaling Playbook"},"thumbnailUrl":"https://www.aversusb.net/api/og?title=ISR%2C%20On-Demand%20Revalidation%2C%20and%2010K%20Product%20Pages%3A%20Our%20Next.js%20Scaling%20Playbook&type=blog","contentReferenceTime":"2026-06-25T11:21:34.944Z","datePublished":"2026-06-25T11:21:34.944Z","dateCreated":"2026-06-25T11:21:34.944Z","dateModified":"2026-06-25T11:21:34.944Z","author":{"@type":"Organization","@id":"https://www.aversusb.net/#organization","name":"A Versus B"},"publisher":{"@type":"Organization","@id":"https://www.aversusb.net/#organization","name":"A Versus B"},"inLanguage":"en-US","isPartOf":{"@type":"WebSite","@id":"https://www.aversusb.net/#website"},"keywords":"nextjs, webdev, javascript, performance","articleSection":"business","wordCount":987,"license":"https://creativecommons.org/licenses/by/4.0/","speakable":{"@type":"SpeakableSpecification","cssSelector":["h1",".article-excerpt",".article-intro","#article-summary"]},"accessMode":["textual"],"accessModeSufficient":[{"@type":"ItemList","itemListElement":["textual"]}],"isAccessibleForFree":true}}