Je deploy start om 23:00 uur. Je kijkt hoe Vercel's build log voorbij 10.000 statische paden scrollt, dan 50.000, dan ergens vast blijft steken bij 89.000. Zes uur later time-out de build. Je directory met 137.000 vermeldingen wordt niet uitgerold omdat je alles tijdens build-time probeerde voor te renderen — een fout die ons 11 dagen kostte en één heel ongemakkelijk gesprek met de klant. We hebben uiteindelijk een productiesysteem uitgerold dat miljoenenfold pageviews serveert, ranking voor duizenden long-tail keywords, en pagina's on-demand regenereert voor $209/maand. De architectuur die het mogelijk maakte vereiste dat we onze instinct om alles voor te renderen afschaafden, opnieuw nadenken over hoe Supabase-queries schalen onder ISR, en één Vercel-configuratiewijziging die responsetijden met 340ms reduceerde. Dit is wat werkelijk werkte.

De stack: Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (hosting + ISR), en een gezonde dosis pragmatisme. We maakten fouten. We liepen tegen muren aan. We herschreven dingen waarvan we dachten dat ze klaar waren. Maar de uiteindelijke architectuur handelt 137.000+ dynamische pagina's af met sub-200ms TTFB wereldwijd, en onze Supabase-rekening blijft onder $100/maand.

Als je iets vergelijkbaars bouwt — een marketplace, een directory, een vermeldingenplatform — dit is het artikel dat ik wens dat bestond toen we begonnen.

Inhoudsopgave

Building a 137K Listing Global Directory with Next.js, Supabase & Vercel ISR

Waarom Deze Stack

We evalueerden veel opties voordat we op Next.js + Supabase + Vercel landet. De kernvereisten waren:

  1. 137.000+ unieke pagina's die zoekmachines kunnen crawlen en indexeren
  2. Sub-second paginalaadtijden wereldwijd (gebruikers in 40+ landen)
  3. Dynamische gegevens — vermeldingen worden dagelijks bijgewerkt, sommige per uur
  4. Full-text zoeken met facettering filteren
  5. Budgetbewust — dit was geen VC-gefinancierde moonshot

We overwogen Astro (geweldig voor statische sites, maar we hadden meer dynamische interactiviteit nodig — hoewel ons Astro-ontwikkelingsteam uitstekende directoryprojecten ermee heeft uitgerold). We keken naar WordPress + WPEngine. We dachten even aan een pure SPA met Algolia.

Next.js won vanwege één killer feature: Incremental Static Regeneration. ISR betekende dat we niet hoefden te kiezen tussen statische performance en dynamische content. We konden beide hebben.

Supabase won van PlanetScale en Neon vanwege het volledige pakket — auth, storage, edge functions, en een werkelijk goede Postgres-implementatie met Row Level Security. Voor een directory heb je dat allemaal nodig.

Vercel was het deployment-doel omdat ISR het best op Vercel werkt (niet verrassend). De integratie is inheems. On-demand revalidatie werkt gewoon.

Wat Met Zelf-Hosting?

We prototypeerden een zelf-gehoste Next.js-setup op Railway. Het werkte, maar ISR op zelf-gehoste Next.js heeft eigenaardigheid. Het cache-invalidatieverhaal is slechter. Je moet je eigen CDN-laag beheren. Voor een team van 3 engineers was de operationele overhead niet de $200/maand waard die we zouden besparen.

De Data Layer: Supabase op Schaal

Onze Supabase-database bevat 137.000 vermeldingen, elk met 40-60 velden. Categorieën, locaties, contactgegevens, rijke beschrijvingen, afbeeldingen, beoordelingen, openingstijden — alles.

Schema-ontwerp

De grootste beslissing was of we een genormaliseerd relationeel schema of een meer documentgeoriënteerde benadering met JSONB-kolommen zouden gebruiken. We kozen hybride:

CREATE TABLE listings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug TEXT UNIQUE NOT NULL,
  title TEXT NOT NULL,
  description TEXT,
  category_id UUID REFERENCES categories(id),
  city_id UUID REFERENCES cities(id),
  country_code TEXT NOT NULL,
  coordinates GEOGRAPHY(POINT, 4326),
  contact JSONB DEFAULT '{}',
  attributes JSONB DEFAULT '{}',
  media JSONB DEFAULT '[]',
  rating_avg NUMERIC(3,2) DEFAULT 0,
  rating_count INTEGER DEFAULT 0,
  status TEXT DEFAULT 'active',
  published_at TIMESTAMPTZ,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  search_vector TSVECTOR
);

CREATE INDEX idx_listings_category ON listings(category_id) WHERE status = 'active';
CREATE INDEX idx_listings_city ON listings(city_id) WHERE status = 'active';
CREATE INDEX idx_listings_country ON listings(country_code) WHERE status = 'active';
CREATE INDEX idx_listings_coordinates ON listings USING GIST(coordinates);
CREATE INDEX idx_listings_search ON listings USING GIN(search_vector);
CREATE INDEX idx_listings_slug ON listings(slug);

Gestructureerde relationele gegevens voor dingen die we filteren (categorieën, steden, landen). JSONB voor semi-gestructureerde spullen die per vermelding variëren (contactmethoden, aangepaste attributen, media-arrays). Dit gaf ons het beste van beide werelden — snelle geïndexeerde query's op de relationele kolommen en flexibiliteit op de rest.

De Zoeken Vector

Die search_vector-kolom is kritiek. We vullen het in met een trigger:

CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
    setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
    setweight(to_tsvector('english', COALESCE(NEW.attributes->>'keywords', '')), 'C');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Dit betekent dat elke vermelding volledig doorzoekbaar is via Postgres zelf. Geen externe zoekservice nodig voor de eerste 100K vermeldingen. We gaan later bespreken wanneer dit instort.

Connection Pooling

Supabase gebruikt PgBouncer voor connection pooling. Met ISR krijg je bursts van serverless-functie-invocaties — elk ervan heeft een databaseverbinding nodig. Zonder pooling put je verbindingen in minuten uit.

We gebruiken de gepoolde verbindingstekenreeks (poort 6543) voor alle serverless-contexten en de directe verbinding (poort 5432) alleen voor migraties en admin-taken. Dit is een van die dingen die voor de hand liggend klinken maar mensen vangen.

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // Server-side only
  {
    db: { schema: 'public' },
    auth: { persistSession: false }
  }
)

Paginageneraatiestrategie: ISR, SSG, en het 137K-probleem

Dit is waar het interessant wordt. En waar we onze grootste vroege fout maakten.

De Naïeve Benadering (Doe Dit Niet)

Onze eerste poging: genereer alle 137.000 pagina's tijdens build-tijd met behulp van generateStaticParams. De build duurde 4 uur en 22 minuten. Vercel's gratis tier heeft een limiet van 45 minuten. Zelfs de Pro tier maxeert op 6 uur. Maar het echte probleem was niet de timeout — het was de feedbacklus. Elk deploy duurde een halve dag. Dat is onwerkbaar.

De ISR-benadering (Wat Werkelijk Werkt)

Hier is de strategie die werd uitgerold:

  1. Bij build-time: Genereer de top 5.000 pagina's (op verkeer) statisch
  2. Bij eerste verzoek: Genereer resterende pagina's on-demand en cache deze
  3. Revalidatie: Op tijd gebaseerd (elke 3600 seconden) + on-demand via webhook
// app/listing/[slug]/page.tsx
import { supabase } from '@/lib/supabase'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  // Only pre-generate top listings by traffic
  const { data } = await supabase
    .from('listings')
    .select('slug')
    .eq('status', 'active')
    .order('rating_count', { ascending: false })
    .limit(5000)

  return (data || []).map((listing) => ({
    slug: listing.slug,
  }))
}

export const revalidate = 3600 // Revalidate every hour

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const { data: listing, error } = await supabase
    .from('listings')
    .select(`
      *,
      category:categories(*),
      city:cities(*, country:countries(*))
    `)
    .eq('slug', params.slug)
    .eq('status', 'active')
    .single()

  if (!listing || error) notFound()

  return <ListingDetail listing={listing} />
}

On-Demand Revalidatie

Wanneer een vermeldingeigenaar hun gegevens bijwerkt, willen we niet tot een uur wachten totdat de pagina vernieuwt. Supabase-webhooks activeren een Next.js API-route:

// app/api/revalidate/route.ts
import { revalidatePath } 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: 'Unauthorized' }, { status: 401 })
  }

  const { slug, type } = await request.json()

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`)
    revalidatePath(`/`) // Revalidate homepage too
  }

  return NextResponse.json({ revalidated: true })
}

Dit geeft ons het beste van beide werelden: static-site performance met dynamic-site versheid. Builds worden voltooid in onder de 8 minuten. Pagina's die niet vooraf zijn gegenereerd, worden bij eerste bezoek gemaakt en in de cache opgeslagen aan de edge.

De Nummers

Metriek Volledige SSG (Naïef) ISR (Productie)
Build-tijd 4u 22m 7m 40s
Pagina's bij deploy 137.000 5.000
Eerste bezoek (niet in cache) N/A ~800ms
Volgende bezoeken ~120ms ~120ms
Revalidatielatentie Volledig redeploy < 2 seconden
Maandelijkse build-minuten Veel over limiet ~230 minuten

Building a 137K Listing Global Directory with Next.js, Supabase & Vercel ISR - architecture

URL-architectuur en SEO op Schaal

Met 137.000 pagina's is URL-structuur niet een nagedachte — het is architectuur. Elke URL is een rankingkans.

De URL-hiërarchie

/                                    → Homepage
/categories/[category-slug]          → Category pages (48 categories)
/locations/[country]/[city]          → Location pages
/listing/[listing-slug]              → Individual listing
/search?q=...&category=...&city=...  → Search results (noindex)

Categorie + locatiekruising pagina's zijn de echte SEO-goudmijn:

/categories/restaurants/us/new-york   → "Restaurants in New York"
/categories/hotels/uk/london          → "Hotels in London"

Deze kruising pagina's worden dynamisch gegenereerd met ISR. Er zijn ongeveer 12.000 geldige combinaties. Elk daarvan targetmaalt een specifieke long-tail zoekterm.

Sitemap-generatie

Met 137K URL's heb je sitemap-indexbestanden nodig. Google's limiet is 50.000 URL's per sitemap.

// app/sitemap/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
  const page = parseInt(params.id)
  const perPage = 45000 // Stay under the 50K limit
  const offset = page * perPage

  const { data: listings } = await supabase
    .from('listings')
    .select('slug, updated_at')
    .eq('status', 'active')
    .order('id')
    .range(offset, offset + perPage - 1)

  const xml = generateSitemapXml(listings)
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml' },
  })
}

We splitsen in 4 sitemaps: sitemap-0.xml tot sitemap-3.xml, waarnaar verwezen wordt door een sitemap-index. Google Search Console indexeerde 98% van ingediende URL's binnen 6 weken.

Gestructureerde Gegevens

Elke vermelding-pagina bevat JSON-LD gestructureerde gegevens. Voor een directory is LocalBusiness-schema kritiek:

const structuredData = {
  '@context': 'https://schema.org',
  '@type': 'LocalBusiness',
  name: listing.title,
  description: listing.description,
  address: {
    '@type': 'PostalAddress',
    addressLocality: listing.city.name,
    addressCountry: listing.city.country.code,
  },
  geo: {
    '@type': 'GeoCoordinates',
    latitude: listing.coordinates?.lat,
    longitude: listing.coordinates?.lng,
  },
  aggregateRating: listing.rating_count > 0 ? {
    '@type': 'AggregateRating',
    ratingValue: listing.rating_avg,
    reviewCount: listing.rating_count,
  } : undefined,
}

Zoeken en Filteren: Het Moeilijke Deel

Zoeken is altijd het moeilijke deel. Altijd.

Fase 1: Postgres Full-Text Zoeken

Voor onze initiële lancering handelde Postgres tsvector zoeken alles af. Het is snel genoeg voor 137K rijen met een GIN-index. Query-tijden waren gemiddeld 40-80ms.

const { data } = await supabase
  .from('listings')
  .select('id, slug, title, description, category:categories(name)')
  .textSearch('search_vector', query, { type: 'websearch' })
  .eq('status', 'active')
  .eq('country_code', countryFilter)
  .order('rating_avg', { ascending: false })
  .range(0, 19)

Fase 2: Wanneer Postgres niet Genoeg Was

Op ongeveer 80.000 vermeldingen begonnen complexe gefacetteerde zoekopdrachten (categorie + locatie + tekst + sortering) 300-500ms te raken. Acceptabel voor de meeste apps, maar onze gebruikers verwachtten onmiddellijke resultaten.

We voegden Typesense toe als een zoeklaag. Niet Algolia (te duur op onze schaal — we zouden $500+/maand betalen). Niet Meilisearch (geweldig, maar Typesense's geo-zoeken was beter voor onze use case).

Typesense draait op een enkele $48/maand Hetzner-instantie. Synchroniseert van Supabase via een nachtelijke volledige opnieuw indexering + real-time webhook-updates. Zoekquery's zijn nu gemiddeld 8-15ms.

Zoekoplossing Query-tijd (p50) Query-tijd (p99) Maandelijkse kosten Gefacetteerd zoeken
Postgres FTS 45ms 320ms $0 (inbegrepen) Beperkt
Typesense 9ms 28ms $48 Uitstekend
Algolia ~5ms ~15ms $500+ Uitstekend
Meilisearch ~8ms ~22ms $48 (zelf-gehost) Goed

Performance Budgets en Edge Caching

We stelden vanaf het begin agressieve prestatiedoelen vast:

  • TTFB: < 200ms (globaal p75)
  • LCP: < 1,5s
  • CLS: < 0,05
  • Totaal paginagewicht: < 300KB (initieel laden)

Vercel Edge Network

ISR-pagina's worden in Vercel's edge network gecacht — 100+ PoP's wereldwijd. Zodra een pagina is gegenereerd en in cache opgeslagen, wordt deze bediend vanaf de dichtstbijzijnde edge-locatie. Dit is de reden waarom TTFB onder de 200ms blijft, zelfs voor gebruikers in Zuidoost-Azië of Zuid-Amerika.

Afbeeldingsoptimalisatie

Elke vermelding heeft 1-8 afbeeldingen. Dat zijn potentieel meer dan een miljoen afbeeldingen. We gebruiken Vercel's ingebouwde afbeeldingsoptimalisatie met next/image:

<Image
  src={listing.media[0]?.url}
  alt={listing.title}
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  loading={index === 0 ? 'eager' : 'lazy'}
  quality={75}
/>

Afbeeldingen worden opgeslagen in Supabase Storage en bediend via Vercel's afbeeldingen CDN. De originele afbeeldingen zijn vaak 2-5MB; na optimalisatie zijn ze 40-120KB. Dit alleen bespaarde ons ruwweg 80% op bandbreedte.

Monitoring en Observability in Productie

Het uitvoeren van 137K pagina's in productie zonder monitoring is als blinddoeks rijden. Hier is onze stack:

  • Vercel Analytics: Core Web Vitals, real user monitoring
  • Sentry: Fouttracking (we vangen ~50 fouten/dag, meestal van bots die rommel sturen)
  • Supabase Dashboard: Databaseperformance, query-analyse
  • Checkly: Synthetische monitoring, 5-minuten-intervallen op kritieke paden
  • Google Search Console: Index-dekking, crawl-statistieken

De meest waardevolle monitoring die we opstelden was een dagelijkse Supabase-query die geïndexeerde pagina's versus totale actieve vermeldingen telt. Als de verhouding onder de 95% daalt, krijgen we een waarschuwing. Dit ving een sitemap-regressie binnen 24 uur na het deployen van een slechte wijziging.

Kostenopstelling: Wat Dit Werkelijk Kost

Mensen vragen altijd naar kosten. Hier is de werkelijke maandelijkse uitgave vanaf Q1 2026:

Service Plan Maandelijkse kosten
Vercel Pro $20
Vercel Bandbreedte (overages) Pay-as-you-go ~$35
Supabase Pro $25
Supabase Database (compute) Kleine instantie $48
Typesense (Hetzner) CX31 $48
Checkly Starter $7
Sentry Team $26
Domein + DNS (Cloudflare) Gratis tier $0
Totaal ~$209/maand

137.000 pagina's serveren met miljoenenfold maandelijkse paginaweergaven voor ongeveer $200/maand. Probeer dit met een traditionele serversetup met WordPress.

Als je een vergelijkbaar project overweegt en wilt begrijpen hoe een architectuur als deze aansluit op je budget, breekt onze prijspagina uiteen hoe we typisch directory- en marketplace-projecten schetsen.

Wat We Anders Zouden Doen

Start met ISR vanaf dag één. We verspilden twee weken met het proberen full SSG te laten werken voordat we accepteerden dat de wiskunde niet uitkwam.

Gebruik Typesense vanaf het begin. Postgres FTS was vroeg in orde, maar het migreren van zoeken mid-project was disruptief. De $48/maand zou het waard geweest zijn van lancering.

Investeer eerder in gegevensvalidatie. Met 137K vermeldingen geïmporteerd uit verschillende bronnen, was gegevenskwaliteit een nachtmerrie. We hadden strengere Zod-schema's en validatiepijplijnen moeten bouwen voordat de eerste import, niet nadat we duizenden kapotte records in productie vonden.

Test met realistische gegevensvolumes in staging. Onze staging-omgeving had 500 vermeldingen. Query's die geweldig op 500 rijen werkten, vielen uiteen bij 137K. We seeden nu staging met een willekeurig steekproef van 20% van productiegegevens.

Als je een directory- of marketplace-build plant en dezelfde valkuilen wilt vermijden, neem contact op met ons team. We hebben dit genoeg keren doorstaan om te weten waar de landmijnen zitten.

Veelgestelde Vragen

Hoe lang duurt het om een directory met 100K+ vermeldingen met Next.js te bouwen? Voor ons team duurden de initiële architectuur en kernfuncties ongeveer 10 weken. Gegevensimport, schoonmaak en validatie voegde nog 3-4 weken toe. Totaal van kickoff tot productielancering was ruwweg 14 weken. Als je met een Next.js-ontwikkelingsteam werkt dat dit eerder heeft gedaan, kun je 2-3 weken van dit besparen.

Kan Supabase 100.000+ rijen voor een directory verwerken? Absoluut. Supabase draait op Postgres, wat miljoenen rijen verwerkt zonder problemen. De sleutel is correct indexeren — zonder indexen op je meest-bevraagde kolommen, daalt de performance snel. Met de indexen die we hierboven beschreven, zijn onze query's op 137K rijen consistent teruggekeerd in onder 50ms voor einzelrecord-zoekopdrachten.

Wat is het verschil tussen ISR en SSG voor grote sites? SSG (Static Site Generation) bouwt elke pagina bij deploy-tijd. ISR (Incremental Static Regeneration) bouwt een subset bij deploy-tijd en genereert de rest on-demand. Voor sites met meer dan ~10.000 pagina's is ISR praktisch verplicht — volledige SSG-bouwsels worden te langzaam voor redelijke implementatiecycli.

Hoe handle je SEO voor 137.000 dynamisch gegenereerde pagina's? Drie dingen zijn het belangrijkst: juiste sitemap-generatie opgesplitst over meerdere bestanden, unieke gestructureerde gegevens (JSON-LD) op elke vermelding-pagina, en ervoor zorgen dat ISR-gegenereerde pagina's juiste HTTP 200-statuscodess terugsturen (geen soft 404's). We genereren ook unieke metatitels en beschrijvingen per pagina met behulp van de vermeldings-gegevens — geen gedupliceerde meta-content.

Is Vercel ISR betrouwbaar voor productie op schaal? Volgens onze ervaring ja. We voeren deze setup nu meer dan 8 maanden uit met 99,98% uptime. De enige incidenten waren zelfvernieuwd — een slecht deploy dat onze revalidatie-webhook brak, en één Supabase-onderhoudsvenster dat 15 minuten gedegradeerd zoeken veroorzaakte. Vercel's edge cache is rotsvast.

Zou ik Algolia of Typesense voor een grote directory moeten gebruiken? Het hangt af van je budget. Algolia is de industriestandaard met de beste developer experience, maar het wordt duur voorbij 100K records — verwacht $500-1000+/maand. Typesense levert 90% van de functionaliteit af tegen een fractie van de kosten wanneer zelf-gehost. We kozen Typesense en hebben het niet betreurd.

Hoe hou je 137.000 vermeldingen up-to-date? We gebruiken een combinatie van benaderingen: on-demand revalidatie geactiveerd door Supabase-webhooks wanneer individuele vermeldingen veranderen, op-tijd gebaseerde ISR-revalidatie (per uur) als vangnet, en een nachtelijke batchklus die op veroudering nagaat en bulk-revalidatie activeert. Vermelding-eigenaren kunnen ook handmatig een paginavernieuwing aanvragen via hun dashboard.

Kan deze architectuur met een headless CMS in plaats van Supabase werken? Ja, maar met afwegingen. Een headless CMS setup zoals Sanity of Contentful werkt goed voor de content management-kant, maar je hebt waarschijnlijk nog steeds een database nodig voor zoeken en complexe query's. We hebben directoryprojecten gebouwd waarbij de redactionele inhoud in een headless CMS leeft en de vermeldings-gegevens in Postgres leven — het is een geldige hybride benadering.