Hoe we een directoryplatform met 137K vermeldingen bouwden met Next.js en Vercel ISR

Vorig jaar hebben we een directoryplatform gelanceerd. 137.000 vermeldingen. Dit was geen klein project. Het was een volledig gerealiseerd platform waarbij elke vermelding een eigen SEO-geoptimaliseerde pagina had. Zoekopdrachten moesten snel zijn, en ja, hosting moest betaalbaar blijven. Hoe hebben we dit gedaan met Next.js, Vercel en Incremental Static Regeneration (ISR)? Riem je vast; hier is het verhaal, inclusief waar het lastig werd.

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

Inhoudsopgave

Waarom een directoryplatform moeilijker is dan het lijkt

Directorysites lijken misschien eenvoudig. Je zou kunnen denken dat een lijstpagina, een detailpagina, wat filters toevoegen en klaar! Maar zodra je verder gaat dan een paar duizend vermeldingen, spiraliseert alles in complexiteit.

Dit is wat er werkelijk aan de hand is:

  • 137.000+ unieke pagina's die elk doorzoekbaar en indexeerbaar moeten zijn
  • Gefacetteerd zoeken over locatie, categorie en meer
  • Verouderde gegevens beheren -- vermeldingen zijn voortdurend in flux met updates en verwijderingen
  • SEO-eisen betekenen dat je niet alleen op client-side rendering kunt vertrouwen
  • Hosting met een beperkt budget maakt het onmogelijk om alle pagina's bij build-tijd te genereren

Na het onderzoeken van veel methoden hebben we Next.js met ISR vastgesteld als onze eerste keuze. We hebben Astro ook overwogen (gebruikt in enkele van onze andere projecten--zie ons Astro-ontwikkelingswerk). Uiteindelijk was de dynamische capaciteit van Next.js met ISR de duidelijke keuze.

Architectuuroverzicht

Hier ziet onze architectuur eruit:

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

De stack

Onderdeel Technologie Waarom
Framework Next.js 14 (App Router) ISR-ondersteuning, React Server Components, route handlers
Hosting Vercel Pro Edge CDN, ISR-infrastructuur, analytics
Database Neon PostgreSQL Serverless Postgres, branching voor previews
Zoeken Meilisearch Cloud Typofout-tolerant, gefacetteerd zoeken, snelle indexering
Cache Upstash Redis Rate limiting, sessie-cache, ISR-coördinatie
CMS (admin) Custom admin + Payload CMS Vermelding-beheer, bulkbewerkingen
CDN/Afbeeldingen Vercel Image Optimization + Cloudinary Vermeldingsfoto's op meerdere breakpoints

Dit is een Next.js-ontwikkelingsproject in de kern, en ISR was de grote verkoper voor ons.

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

De ISR-strategie die werkelijk op schaal werkt

Laten we ter zake komen: als je probeert 137.000 pagina's statisch bij build-tijd te genereren, vraag je om problemen. Serius, nodig die hoofdpijn niet uit. Zelfs met de parallelle generatie van Next.js zouden builds langer dan 45 minuten kunnen duren, wat elke implementatie tot een nachtmerrie maakt.

ISR laat je pagina's genereren wanneer nodig en cacht ze aan de edge. Standaard ISR is prima, maar voor ons waren aanpassingen essentieel.

De driestaps paginastrategie

We verdeelden onze vermeldingen in drie categorieën:

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

export async function generateStaticParams() {
  // Stap 1: Pre-genereer de top 2.000 vermeldingen met het meeste verkeer
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

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

// Stap 2 & 3: Gegenereerd on-demand via ISR
export const revalidate = 3600; // 1 uur voor de meeste vermeldingen

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

  if (!listing) {
    notFound();
  }

  // Dynamische revalidatie op basis van vermelding-categorie
  // Premium-vermeldingen revalideren elke 10 minuten
  // Standaard-vermeldingen elke uur
  // Gearchiveerde vermeldingen elke 24 uur

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

Stap 1 (2.000 pagina's): Deze vermeldingen met veel verkeer worden bij build-tijd pre-gegenereerd. Ze zijn verantwoordelijk voor het meeste organische zoekverkeer. Ze zijn altijd klaar.

Stap 2 (35.000 pagina's): Gegenereerd wanneer eerst aangevraagd, in cache geplaatst voor een uur. Deze vermeldingen hebben stabiel verkeer, dus de eerste bezoeker na cache-verloop krijgt een server-gerenderde maar snelle pagina. Iedereen anders krijgt de gecachte versie.

Stap 3 (100.000 pagina's): Gegenereerd bij eerste aanvraag, in cache geplaatst voor 24 uur. Deze vermeldingen krijgen bijna geen verkeer, dus er is geen reden om resources te verspillen.

On-demand revalidatie voor realtime updates

Meeste gevallen worden gedekt door getimede revalidaties, maar wat gebeurt er als een restauranteigenaar zojuist haar openingstijden heeft bijgewerkt? Nou, we hebben de on-demand revalidatie van Next.js geïmplementeerd met behulp van 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() });
}

Met ons admin-paneel en webhooks die naar dit eindpunt spreken, krijgt elke vermelding-wijzigaar een versse pagina bij de volgende aanvraag. Snel, toch?

137K pagina's beheren zonder build-tijden te verwoesten

Build-tijden maakten ons echt bang! Dit hebben we gevonden:

Strategie Build-tijd Latentie eerste aanvraag Latentie cache hit
Volledige SSG (alle 137K pagina's) ~52 minuten ~40ms ~40ms
ISR (2K pre-built) ~3,5 minuten ~180ms (cold) ~40ms
Volledige SSR (geen caching) ~45 seconden ~250ms N/A
Onze hybrid-aanpak ~3,5 minuten ~150ms (cold) ~35ms

Onze ISR-aanpak reduceerde build-tijden van een gruwelijk uur tot iets onder de 4 minuten. Dat is het verschil tussen implementaties vervloeken en, nou ja, koffie drinken terwijl ze lopen.

De `dynamicParams`-instelling

Hier is een cruciaal detail: zet dynamicParams = true om ISR pagina's buiten generateStaticParams te laten genereren. Het klinkt voor de hand liggend, maar je zou versteld staan hoe vaak dit wordt vergeten.

export const dynamicParams = true; // Sta on-demand generatie toe

Parallelle routesegmenten

Voor pagina's met categorieën en locaties hebben we parallelle routesegmenten gebruikt zodat het filter en de vermeldingsrasters onafhankelijk konden laden:

// 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>
  );
}

Dit betekent dat je filters op zichzelf in cache kunnen worden geplaatst. Verander een filter, en alleen het vermeldingsraster wordt opnieuw gerenderd. Snel!

Database- en zoeklaag

PostgreSQL op Neon

We hebben Neon gekozen vanwege de serverless-voordelen zoals schaling en preview branches. Het soort dingen dat ons leven makkelijker maakte.

Onze tabel met vermeldingen is eenvoudig maar afhankelijk van veel indexering:

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);

Waarom de GiST-index op locatie? Het gaat allemaal om die nauwkeurige georuimtelijke query's. "Koffiehuizen dicht bij mij" is niet zomaar wazig; het is een echte berekening.

Meilisearch voor zoeken

Als je lijst groeit zoals die van ons, zal PostgreSQL's tekstzoeken niet volstaan, en dat is waar Meilisearch je helpt. Het won van Algolia voor ons, vooral op prijs ($30/maand vs $200+) en de indrukwekkende typofout-tolerantie.

// 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'],
  });
}

Elke vijf minuten synchroniseren vermeldingen met een taak. We doen wekelijks een volledige herindexering, alleen voor de zekerheid. Beter voorzichtig, toch?

SEO op schaal: Sitemaps, Structured Data en Crawl Budget

Voor een platform met 137.000 pagina's is SEO niet zomaar nice-to-have; het is levensnoodzakelijk. Hier is hoe we dit hebben aangepakt:

Dynamische sitemaps

Je kunt 137.000 URL's niet in één sitemapbestand dumpen. De limiet is 50.000 URL's volgens de specificatie. Dus wat doen we? We genereren een sitemap-index die naar gesegmenteerde onderdelen wijst:

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Dit genereert de 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;
}

Gesegmenteerde sitemaps bevatten elk 10.000 vermeldingen, compleet met tijdstempels. Google graaft er ongeveer 8.000-12.000 pagina's dagelijks in.

Structured Data

Elke vermeldingspagina bevat 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,
    },
  };
}

Dit soort structured data verhoogde onze rankings aanzienlijk, met Google die veel voorkeur geeft aan zulke precieze info.

Prestatiebanken

Echte metriek van onze live site vanaf begin 2025:

Metriek Waarde Doel
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 (gecacht) / 190ms (cold ISR) < 200ms
Lighthouse Performance Score 94-98 > 90
Build-tijd 3 min 22 sec < 5 min
Cache Hit Rate 94,7% > 90%

Die hoge cache hit rate? Ja, maar liefst 94,7% van onze pagina's komen rechtstreeks van Vercel's edge CDN--geen extra computing nodig. Het is win-win voor snelheid en kosten.

Kostenoverzicht op Vercel

Laten we tot de dollars en cents komen. Wie houdt niet van een goed koopje?

Service Maandelijkse kosten (2025) Opmerkingen
Vercel Pro $20/zitplaats Voor pro-level functies en limieten
Vercel bandwidth ~$55 ~600GB/maand met ISR-caching
Vercel serverless functions ~$40 Voor ISR-werk + API-spullen
Neon PostgreSQL $19 (Scale plan) 10GB opslag, schaalbare compute
Meilisearch Cloud $30 500K docs, dedicated instance
Upstash Redis $10 10K opdrachten/dag gemiddeld
Cloudinary $25 Afbeeldingsopslag en transformaties
Totaal ~$199/maand Voor 137K pagina's, ~200K maandelijkse bezoekers

Onder de $200/maand om een beest met 137.000 pagina's uit te voeren. Versus een traditionele serverinstallatie? Je zou geld verspillen aan VM's, beheerde DB's, CDN's en een fulltime DevOps om alles in de gaten te houden.

Als je op deze schaal speelt en een chat wilt, neem contact op of bekijk ons pricing.

Fouten die we hebben gemaakt en wat we zouden veranderen

Fout 1: On-demand revalidatie niet meteen instellen

We vertrouwden aanvankelijk alleen op getimede revalidatie. Laat me je zeggen, slecht idee. Eigenaren van vermeldingen zouden hun info aanpassen en meteen controleren. Zien ze oude gegevens? Niet echt een vertrouwenbooster. Revalidatie moest MVP zijn.

Fout 2: De complexiteit van de sitemap onderschatten

Onze eerste poging bij een sitemap propte alles in één serverless functie. Timeout. Vercel geeft je 10 seconden (60 op Pro) voor timeout. We hebben geleerd. Segmenteer die dingen.

Fout 3: Afbeeldingsoptimalisatiekosten

Aanvankelijk verwerkte Vercel alle optimalisaties van vermeldingsfoto's. Een gek aantal afbeeldingen betekende wilde kosten. We hebben die taak met Cloudinary verdeeld, met Vercel's magie voorbehouden voor UI-must-haves.

Fout 4: React Server Components niet agressief genoeg gebruiken

Sommige initiële pagina's waren volgeladen met te veel 'use client' commando's. Gevolg? Te veel JavaScript verzonden. Heroriëntatie op Server Components maakte onze JavaScript-bundel licht als een veertje (62% verlaging!).

Wat we volgende keer anders zouden doen

Volgende keer zouden we absoluut Next.js met zoiets als een Payload CMS van het begin af aan gebruiken in plaats van van scratch een admin-paneel in elkaar zetten. Wat een tijdbesparaar dat zou zijn geweest!

We zouden ook de nieuwste unstable_cache van Vercel (of gewoon cache nu) voor queryresultaten nauw overwegen, verder dan de standaard ISR-caching.

Veelgestelde vragen

Kan Next.js ISR echt honderdduizenden pagina's aan?
Absoluut. We hebben het bewezen. Pre-genereer je pagina's met het meeste verkeer (meestal 1-5%) met behulp van generateStaticParams en laat ISR de rest verzorgen. Vercel's edge zorgt er vandaar voor, wat snelle laadtijden wereldwijd garandeert.

Hoeveel kost het om een groot directoryplatform op Vercel uit te voeren?
Voor ons ongeveer $199/maand voor 137K vermeldingen met 200.000 maandelijkse bezoekers. Kosten zullen variëren, natuurlijk, maar als je die zoete caching-modus raakt, kan ISR je veel besparen.

Wat is het verschil tussen ISR en SSR voor directorysites?
ISR genereert pagina's eenmaal per revalidatieinterval en cacht ze, terwijl SSR pagina's bij elke aanvraag van scratch genereert. ISR is efficiënter voor vermeldingen waarbij gegevens niet elke minuut veranderen.

Hoe handle je zoeken op een statisch gegenereerde directory?
Zoekinteracties gaan rechtstreeks naar Meilisearch, met API-oproepen om mee. Zoekresultaten worden client-side gerenderd, terwijl vermeldingspagina's ISR-ondersteuning hebben. Het is de beste mix van statisch en dynamisch.

Welk revalidatieinterval moet ik gebruiken voor ISR op een directorySite?
Hangt af van wijzigingsfrequentie. We gebruiken een gelaagde aanpak: 10 min voor premiums, 1 uur voor standaard, en 24 uur voor rustigere vermeldingen. Voeg on-demand revalidatie toe voor onmiddellijke wijzigingen.

Hoe genereer je sitemaps voor 137.000 pagina's zonder timeout?
Segmentatie is je vriend. Snijd ze in stukken van 10.000. Route ze door een sitemap-index. Elk stuk moet comfortabel binnen timeoutlimieten blijven.

Is Next.js het beste framework voor het bouwen van directoryplatforms?
Yep, voor zware gewichten--vooral met ISR. Voor ultra-eenvoudige, zelden veranderende lijsten? Astro kan een lichtgewicht optie zijn. We hebben beide gebouwd; de keuze hangt af van je werkbelasting en behoeften.

Hoe voorkom je dat verouderde gegevens de gebruikerservaring schaden met ISR?
Het mengen van op tijd gebaseerde en on-demand revalidatie helpt. Combineer dat met client-side SWR of React Query voor ultra-verse gegevens. ISR voert je shell en realtime glanst selectief door.