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
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() });
}
Triggers for on-demand revalidation:
- Price change detected — our price monitoring pipeline checks retailer APIs every 4 hours
- New review ingested — when our review aggregation pipeline processes a new batch from Reddit, Amazon, or RTINGS
- Product launch/update — manufacturer announces a new model or firmware update
- 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}`,
}),
});
}
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 — 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_cachewith 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
| 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
- Don't pre-build everything. Only pre-build your top pages. Let ISR handle the long tail.
- On-demand > time-based. Time-based revalidation is a fallback. Event-driven revalidation is the goal.
- Warm your pages. Don't let real users hit cold starts. Pre-fetch after content generation.
-
Cache in layers. ISR for pages, Redis for data,
unstable_cachefor queries. Each layer serves a different freshness requirement. - 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)