Last year, we launched a directory platform. 137,000 listings. This wasn't some minor endeavor. It was a fully realized platform where every listing had its own SEO-optimized page. Searches needed to be snappy, and yes, hosting had to remain affordable. So, how'd we do it with Next.js, Vercel, and Incremental Static Regeneration (ISR)? Buckle up; here’s the story, including where things got tricky.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR

Table of Contents

Why a Directory Platform Is Harder Than It Looks

Directory sites might seem straightforward. You might think a list page, a detail page, sprinkle some filters, and voilà! Done. But once you're beyond a few thousand listings, everything spirals into complexity.

Here’s what’s truly going on:

  • 137,000+ unique pages that each must be crawlable and indexable
  • Faceted search across location, category, and more
  • Managing stale data -- listings are in perpetual flux with updates and removals
  • SEO demands mean you can’t just rely on client-side rendering
  • Hosting on a budget eliminates generating all pages at build time

After examining a bunch of methods, we pinned down Next.js with ISR as our go-to. We did consider Astro too (used in some of our other gigs--see our Astro development work). In the end, Next.js's dynamic capability with ISR was the no-brainer.

Architecture Overview

Here's what our architecture looks like:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Vercel      │────▶│  Next.js App │────▶│  PostgreSQL  │
│   Edge CDN    │     │  (ISR)       │     │  (Neon)      │
└──────────────┘     └──────────────┘     └──────────────┘
                            │                     │
                            ▼                     ▼
                     ┌──────────────┐     ┌──────────────┐
                     │  Redis       │     │  Meilisearch │
                     │  (Upstash)   │     │  (Cloud)     │
                     └──────────────┘     └──────────────┘

The Stack

Component Technology Why
Framework Next.js 14 (App Router) ISR support, React Server Components, route handlers
Hosting Vercel Pro Edge CDN, ISR infrastructure, analytics
Database Neon PostgreSQL Serverless Postgres, branching for previews
Search Meilisearch Cloud Typo-tolerant, faceted search, fast indexing
Cache Upstash Redis Rate limiting, session cache, ISR coordination
CMS (admin) Custom admin + Payload CMS Listing management, bulk operations
CDN/Images Vercel Image Optimization + Cloudinary Listing photos at multiple breakpoints

This is a Next.js development project at its core, and ISR was the big seller for us.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR - architecture

The ISR Strategy That Actually Works at Scale

Let's cut to the chase: if you attempt to statically generate 137,000 pages at build time, you're asking for trouble. Seriously, don't invite that headache. Even with Next.js’s parallel generation, builds could stretch beyond 45 minutes, turning every deployment into a nightmare.

ISR lets you generate pages as needed and caches them at the edge. Default ISR is great, but for us, tweaks were essential.

The Three-Tier Page Strategy

We divided our listings into three tiers:

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

export async function generateStaticParams() {
  // Tier 1: Pre-generate the top 2,000 highest-traffic listings
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

  return topListings.map((listing) => ({
    slug: listing.slug,
  }));
}

// Tier 2 & 3: Generated on-demand via ISR
export const revalidate = 3600; // 1 hour for most listings

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const listing = await getListingBySlug(params.slug);

  if (!listing) {
    notFound();
  }

  // Dynamic revalidation based on listing tier
  // Premium listings revalidate every 10 minutes
  // Standard listings every hour
  // Archived listings every 24 hours

  return <ListingDetail listing={listing} />;
}

Tier 1 (2,000 pages): These high-traffic listings get pre-generated at build time. They're responsible for most organic search traffic. They're always ready.

Tier 2 (35,000 pages): Generated when first requested, cached for an hour. These listings have steady traffic, so the first visitor post-cache expiry gets a server-rendered but quick page. Everyone else gets the cached version.

Tier 3 (100,000 pages): Generated on first request, cached for 24 hours. These listings barely see action, so there's no need to waste resources.

On-Demand Revalidation for Real-Time Updates

Most cases are covered by timed revalidations, but what about that restaurant owner who just updated her hours? Well, we rolled out Next.js’s on-demand revalidation using route handlers:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } 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 { slug, type } = await request.json();

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`);
    revalidateTag(`listing-${slug}`);
  } else if (type === 'category') {
    revalidateTag(`category-${slug}`);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

With our admin panel and webhooks speaking to this endpoint, any list-changer gets a fresh page next request. Speedy, isn't it?

Handling 137K Pages Without Blowing Up Build Times

Build times honestly scared us! Here’s what we found:

Strategy Build Time First Request Latency Cache Hit Latency
Full SSG (all 137K pages) ~52 minutes ~40ms ~40ms
ISR (2K pre-built) ~3.5 minutes ~180ms (cold) ~40ms
Full SSR (no caching) ~45 seconds ~250ms N/A
Our hybrid approach ~3.5 minutes ~150ms (cold) ~35ms

Our ISR approach cut build times from an agonizing hour to just under 4 minutes. That’s the difference between dreading deployments and, well, drinking coffee while they run.

The `dynamicParams` Setting

Here’s a crucial tidbit: keep dynamicParams = true to allow ISR to generate pages outside generateStaticParams. It sounds obvious, but you’d be shocked at how often this gets overlooked.

export const dynamicParams = true; // Allow on-demand generation

Parallel Route Segments

For pages with categories and locations, we tapped into parallel route segments so the filter and listing grids could load independently:

// app/directory/[category]/layout.tsx
export default function CategoryLayout({
  children,
  filters,
}: {
  children: React.ReactNode;
  filters: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[280px_1fr] gap-6">
      <aside>{filters}</aside>
      <main>{children}</main>
    </div>
  );
}

This means your filters can be cached by themselves. Change a filter, and only the listing grid re-renders. Snappy!

Database and Search Layer

PostgreSQL on Neon

We selected Neon for its serverless perks like scaling and preview branches. The kind of stuff that made our lives easier.

Our listings table is straightforward but relies heavily on indexing:

CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);

Why the GiST index on location? It's all about those precise geospatial queries. "Coffee shops near me" isn't just fluff; it’s a real calculation.

If your list is swelling like ours, PostgreSQL’s text search won't cut it, and that’s where Meilisearch steps in. It beat Algolia for us, mainly on price ($30/month vs $200+) and its impressive typo tolerance.

// lib/search.ts
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_API_KEY!,
});

export async function searchListings(query: string, filters: FilterParams) {
  const index = client.index('listings');

  return index.search(query, {
    filter: buildFilterString(filters),
    facets: ['category', 'city', 'priceRange', 'rating'],
    limit: 24,
    offset: filters.page * 24,
    attributesToHighlight: ['name', 'description'],
  });
}

Every five minutes, listings sync up with a job. We do a full re-index weekly just in case. Better safe, right?

SEO at Scale: Sitemaps, Structured Data, and Crawl Budget

For a platform with 137,000 pages, SEO isn’t just nice to have; it’s life or death. Here’s how we pulled it off:

Dynamic Sitemaps

You can’t dump 137,000 URLs in one sitemap file. The limit is 50,000 URLs according to the spec. So, what do we do? We generate a sitemap index pointing to segmented pieces:

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // This generates the sitemap index
  const totalListings = await db.listing.count({ where: { status: 'active' } });
  const sitemapCount = Math.ceil(totalListings / 10000);

  const sitemaps = [];

  for (let i = 0; i < sitemapCount; i++) {
    sitemaps.push({
      url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
      lastModified: new Date(),
    });
  }

  return sitemaps;
}

Segmented sitemaps carry 10,000 listings each, complete with timestamps. Google digs in at around 8,000-12,000 pages daily.

Structured Data

Every listing page packs in LocalBusiness schema markup:

function generateStructuredData(listing: Listing) {
  return {
    '@context': 'https://schema.org',
    '@type': 'LocalBusiness',
    name: listing.name,
    description: listing.description,
    address: {
      '@type': 'PostalAddress',
      streetAddress: listing.address,
      addressLocality: listing.city,
      addressRegion: listing.state,
      postalCode: listing.zip,
    },
    aggregateRating: listing.reviewCount > 0 ? {
      '@type': 'AggregateRating',
      ratingValue: listing.avgRating,
      reviewCount: listing.reviewCount,
    } : undefined,
    geo: {
      '@type': 'GeoCoordinates',
      latitude: listing.lat,
      longitude: listing.lng,
    },
  };
}

This kind of structured data turbocharged our rankings, with Google giving big preference to such precise info.

Performance Benchmarks

Real metrics from our live site as of early 2025:

Metric Value Target
Largest Contentful Paint (LCP) 1.1s (p75) < 2.5s
First Input Delay (FID) 12ms (p75) < 100ms
Cumulative Layout Shift (CLS) 0.02 (p75) < 0.1
Time to First Byte (TTFB) 85ms (cached) / 190ms (cold ISR) < 200ms
Lighthouse Performance Score 94-98 > 90
Build Time 3 min 22 sec < 5 min
Cache Hit Rate 94.7% > 90%

That high cache hit rate? Yep, a whopping 94.7% of our pages are straight from Vercel's edge CDN--no extra computing needed. It's a win-win for speed and costs.

Cost Breakdown on Vercel

Let's get to the dollars and cents. Who doesn’t love a good bargain?

Service Monthly Cost (2025) Notes
Vercel Pro $20/seat For pro-level features and limits
Vercel bandwidth ~$55 ~600GB/month with ISR caching
Vercel serverless functions ~$40 For ISR work + API stuff
Neon PostgreSQL $19 (Scale plan) 10GB storage, scalable compute
Meilisearch Cloud $30 500K docs, dedicated instance
Upstash Redis $10 10K commands/day average
Cloudinary $25 Image storage and transformations
Total ~$199/month For 137K pages, ~200K monthly visitors

Under $200/month to run a beast with 137,000 pages. Versus a traditional server setup? You'd bleed money on VMs, managed DBs, CDNs, and a full-time DevOps to baby it all.

If you're playing at this scale and want a chat, contact us or peek at our pricing.

Mistakes We Made and What We'd Change

Mistake 1: Not Setting Up On-Demand Revalidation from Day One

We initially relied just on timed revalidation. Let me tell you, bad move. Listing owners would tweak their info and check instantly. Seeing old data? Not a confidence booster. Revalidation needed to be MVP.

Mistake 2: Underestimating the Sitemap Complexity

Our first crack at a sitemap jammed everything into one serverless function. Cue timeouts. Vercel gives you 10 seconds (60 on Pro) before timeout. We learned. Segment those suckers.

Mistake 3: Image Optimization Costs

Originally, Vercel handled all listing photo optimizations. A crazy amount of images meant wild costs. We split that duty with Cloudinary, reserving Vercel's magic for UI must-haves.

Mistake 4: Not Using React Server Components Aggressively Enough

Some initial pages were packed with too many 'use client' commands. Result? Too much JavaScript shipped. Refocusing on Server Components made our JavaScript bundle light as a feather (62% cut!).

What We'd Do Differently

Next time, we'd absolutely pair Next.js with something like a Payload CMS from the get-go instead of hacking together an admin panel from scratch. What a time-saver that would've been!

We'd also closely consider Vercel's latest unstable_cache (or just cache now) for query results beyond the standard ISR caching.

FAQ

Can Next.js ISR really handle hundreds of thousands of pages?


Absolutely. We've walked the walk. Pre-generate your top-traffic pages (usually 1-5%) using generateStaticParams and let ISR take care of the rest. Vercel's edge takes over from there, ensuring fast load times globally.

How much does it cost to run a large directory site on Vercel?


For us, it's about $199/month for 137K listings with 200,000 monthly visitors. Costs will vary, sure, but hit that sweet caching stride, and ISR can save you big time.

What's the difference between ISR and SSR for directory sites?


ISR generates pages once per revalidation interval and caches them, while SSR generates pages from scratch upon every request. ISR's more efficient for listings where data doesn’t change every minute.

How do you handle search on a statically generated directory?


Search interactions go directly to Meilisearch, with API calls to cover. Search results get rendered client-side, whereas listing pages are ISR-backed. It's the best mix of static and dynamic.

What revalidation interval should I use for ISR on a directory site?


Depends on change frequency. We use a tiered approach: 10 min for premiums, 1-hour for standards, and 24 hours for quieter listings. Sprinkle in on-demand revalidation for instant changes.

How do you generate sitemaps for 137,000 pages without timing out?


Segmentation’s your friend. Slice them into chunks of 10,000. Route them through a sitemap index. Each chunk should stay comfortably within timeout limits.

Is Next.js the best framework for building directory platforms?


Yep, for heavy hitters--especially with ISR. For ultra-simple, rarely changing lists? Astro can be a lightweight option. We've crafted both; the choice hinges on your workload and needs.

How do you prevent stale data from hurting user experience with ISR?


Blending time-based and on-demand revalidation helps. Pair that with client-side SWR or React Query for ultra-fresh data. ISR feeds your shell while real-time shines through selectively.