Vorig jaar hebben we een wereldwijde directory uitgebracht met 137.000 aanbiedingen. Niet een prototype. Niet een "we optimaliseren later" MVP. Een productiesysteem dat miljarden paginaweergaven serveert, voor duizenden long-tail-trefwoorden rankt, en pagina's on-demand regenereert zonder problemen. Dit is het verhaal van hoe we het hebben gebouwd — en de architectuurbeslissingen die dit mogelijk maakten.

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 die we dachten af te hebben. Maar de uiteindelijke architectuur handelt 137.000+ dynamische pagina's af met een TTFB van onder de 200ms wereldwijd, en onze Supabase-rekening blijft onder de €100 per maand.

Als je iets vergelijkbaars bouwt — een marketplace, een directory, een aanbiedingsplatform — dit is het artikel dat ik wilde 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 landden op Next.js + Supabase + Vercel. De kernvereisten waren:

  1. 137.000+ unieke pagina's die zoekmachines kunnen crawlen en indexeren
  2. Sublicatie paginasnelheden wereldwijd (gebruikers in 40+ landen)
  3. Dynamische data — aanbiedingen worden dagelijks bijgewerkt, sommige elk uur
  4. Full-text search met gefacetteerd filteren
  5. Budgetbewust — dit was geen VC-gefinancierd moonshot project

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

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

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

Vercel was het implementatiedoel omdat ISR het beste werkt op Vercel (niet verrassend). De integratie is ingebouwd. On-demand revalidatie werkt gewoon.

Wat met zelfhosting?

We maakten een prototype van een zelfgehoste Next.js-setup op Railway. Het werkte, maar ISR op zelfgehoste Next.js heeft eigenaardigheden. 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 per maand waard die we zouden besparen.

De data-laag: Supabase op schaal

Onze Supabase-database bevat 137.000 aanbiedingen, elk met 40-60 velden. Categorieën, locaties, contactgegevens, uitgebreide beschrijvingen, afbeeldingen, beoordelingen, openingsuren — alles.

Schemaontwerp

De grootste beslissing was of we een genormaliseerd relationeel schema of een meer document-georiënteerde benadering met JSONB-kolommen gebruikten. We gingen hybrid:

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 data voor dingen waarnaar we filteren (categorieën, steden, landen). JSONB voor semi-gestructureerde dingen die per aanbieding 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 zoekvektor

Die search_vector-kolom is kritiek. We vullen het 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 aanbieding volledig doorzoekbaar is via Postgres zelf. Geen externe zoekservice nodig voor de eerste 100K aanbiedingen. We praten later over wanneer dit faalt.

Verbindingspooling

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

We gebruiken de gepoelde verbindingstekenreeks (port 6543) voor alle serverloze contexten en de directe verbinding (port 5432) alleen voor migraties en beheertaken. Dit is een van die dingen die voor de hand liggend klinken maar mensen pakken.

// 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!, // Alleen server-side
  {
    db: { schema: 'public' },
    auth: { persistSession: false }
  }
)

Paginageneratiestrateggie: 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 op bouwmoment met generateStaticParams. De build duurde 4 uur en 22 minuten. Vercel's gratis laag heeft een limiet van 45 minuten. Zelfs de Pro-laag is gemaximaliseerd op 6 uur. Maar het echte probleem was niet de timeout — het was de feedbackloop. Elke implementatie duurde een halve dag. Dat is onwerkbaar.

De ISR-benadering (wat werkelijk werkt)

Hier is de strategie die is uitgebracht:

  1. Op bouwmoment: genereer de top 5.000 pagina's (op verkeer) statisch
  2. Bij eerste aanvraag: genereer resterende pagina's on-demand en cache ze
  3. Revalidatie: op basis van tijd (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() {
  // Genereer alleen top-aanbiedingen op verkeer
  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 // Revalideer elk uur

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 aanbieder hun gegevens bijwerkt, willen we niet tot een uur wachten tot de pagina vernieuwt. Supabase-webhooks triggeren 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(`/`) // Revalideer ook de homepage
  }

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

Dit geeft ons het beste van beide werelden: prestaties van statische sites met versheid van dynamische sites. Builds worden in minder dan 8 minuten voltooid. Pagina's die niet vooraf zijn gegenereerd, worden bij eerste bezoek gemaakt en cached aan de edge.

De getallen

Metriek Volledige SSG (naïef) ISR (productie)
Bouwduur 4u 22m 7m 40s
Pagina's bij implementatie 137.000 5.000
Eerste bezoek (niet gecached) N.v.t. ~800ms
Volgende bezoeken ~120ms ~120ms
Revalidatievertraging Volledige herimplementatie < 2 seconden
Maandelijkse bouwminuten Veel te veel ~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 geen bijzaak — het is architectuur. Elke URL is een rankingkans.

De URL-hiërarchie

/                                    → Homepage
/categories/[category-slug]          → Categoriepagina's (48 categorieën)
/locations/[country]/[city]          → Locatiepagina's
/listing/[listing-slug]              → Individuele aanbieding
/search?q=...&category=...&city=...  → Zoekresultaten (noindex)

Categorie + locatie-intersectiepagina's zijn de echte SEO-goudmijn:

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

Deze intersectiepagina's worden dynamisch gegenereerd met ISR. Er zijn ongeveer 12.000 geldige combinaties. Elk ervan richt zich op een specifiek long-tail-trefwoord.

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 // Blijf onder de limiet van 50K
  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 wordt verwezen door een sitemap-index. Google Search Console indexeerde 98% van ingediende URL's binnen 6 weken.

Gestructureerde gegevens

Elke aanbiedingspagina 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 lastige deel

Zoeken is altijd het lastige deel. Altijd.

Voor onze eerste lancering handelde Postgres tsvector search alles af. Het is snel genoeg voor 137K rijen met een GIN-index. Query's duurden 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

Bij ongeveer 80.000 aanbiedingen begonnen complexe gefacetteerde zoekopdrachten (categorie + locatie + tekst + sortering) 300-500ms te bereiken. Acceptabel voor de meeste apps, maar onze gebruikers verwachtten instant resultaten.

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

Typesense draait op een enkele €48/maand Hetzner-instantie. Syncs van Supabase via een nachtelijke volledige herindex + real-time webhook-updates. Query's duren 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 (zelfgehost) Goed

Prestatiebegrenzingen en edge-caching

We stelden agressieve prestatiedoelen van dag één in:

  • TTFB: < 200ms (wereldwijd p75)
  • LCP: < 1,5s
  • CLS: < 0,05
  • Totaal paginagewicht: < 300KB (eerste load)

Vercel Edge Network

ISR-pagina's worden in cache opgeslagen op het Vercel edge network — 100+ PoPs wereldwijd. Zodra een pagina gegenereerd en gecached is, serveert het van de dichtstbijzijnde edge-locatie. Dit is waarom TTFB onder de 200ms blijft, zelfs voor gebruikers in Zuidoost-Azië of Zuid-Amerika.

Afbeeldingsoptimalisatie

Elke aanbieding heeft 1-8 afbeeldingen. Dat is 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 geserveerd via Vercel's afbeeldings-CDN. De originele afbeeldingen zijn vaak 2-5MB; na optimalisatie zijn het 40-120KB. Dit alleen bespaarde ons ongeveer 80% op bandbreedte.

Monitoring en waarneembaarheid in productie

137K pagina's in productie draaien zonder monitoring is als blind 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: Databaseprestaties, query-analyse
  • Checkly: Synthetische monitoring, 5-minuten-intervals op kritieke paden
  • Google Search Console: Indexdekking, crawlstatistieken

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

Kostenoverzicht: wat dit werkelijk kost

Mensen vragen altijd naar kosten. Hier is de echte maandelijkse uitgave vanaf Q1 2025:

Service Plan Maandelijkse kosten
Vercel Pro €20
Vercel bandbreedte (overschrijdingen) Pay-as-you-go ~€35
Supabase Pro €25
Supabase database (compute) Small-instantie €48
Typesense (Hetzner) CX31 €48
Checkly Starter €7
Sentry Team €26
Domein + DNS (Cloudflare) Gratis laag €0
Totaal ~€209/maand

137.000 pagina's serveert met miljoenen maandelijkse paginaweergaven voor ongeveer €200/maand. Probeer dat met een traditionele WordPress-serversetup.

Als je overweegt een soortgelijk project en wilt begrijpen hoe een architectuur als deze kaart op uw budget, onze prijzenpagina geeft details over hoe we typisch directory- en marketplace-projecten bepalen.

Wat we anders zouden doen

Begin met ISR van dag één. We verspilden twee weken door te proberen volledig SSG te laten werken voordat we de wiskundige niet accepteerden.

Gebruik Typesense van het begin. Postgres FTS was vroeg prima, maar migreren van zoeken halverwege project was disruptief. De €48/maand zou het waard zijn geweest bij lancering.

Investeer eerder in datavalidatie. Met 137K aanbiedingen geïmporteerd uit verschillende bronnen, was gegevenskwaliteit een nachtmerrie. We zouden strengere Zod-schemas en validatiepijplijnen moeten hebben gebouwd voordat de eerste import, niet nadat we duizenden verbroken records in productie vonden.

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

Als je een directory of marketplace-build plant en dezelfde valkuilen wilt vermijden, neem contact op met ons team. We zijn hier genoeg doorheen gegaan om te weten waar de landmijnen liggen.

Veelgestelde vragen

Hoe lang duurt het om een 100K+ aanbiedingsdirectory met Next.js te bouwen? Voor ons team duurde de initiële architectuur en kernfuncties ongeveer 10 weken. Gegevensimport, opschoning en validatie voegden nog 3-4 weken toe. Totaal van kickoff tot lancering in productie was ongeveer 14 weken. Als je met een Next.js-ontwikkelingsteam werkt dat dit al eerder heeft gedaan, kun je 2-3 weken afsnijden.

Kan Supabase 100.000+ rijen voor een directory verwerken? Absoluut. Supabase draait op Postgres, dat miljoenen rijen zonder problemen verwerkt. De sleutel is juiste indexering — zonder indexen op je meest-gequery's kolommen, degradeert prestaties snel. Met de indexen die we hierboven beschreven, zijn onze query's op 137K rijen consistent terug in onder 50ms voor single-record lookups.

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

Hoe hanteer je SEO voor 137.000 dynamisch gegenereerde pagina's? Drie dingen zijn het belangrijkst: juiste sitemap-generatie opgesplitst in meerdere bestanden, unieke gestructureerde gegevens (JSON-LD) op elke aanbiedingspagina, en ervoor zorgen dat ISR-gegenereerde pagina's juiste HTTP 200-statuscode retourneren (geen soft 404's). We genereren ook unieke meta-titels en beschrijvingen per pagina met behulp van aanbiedingsgegevens — geen gedupliceerde meta-content.

Is Vercel ISR betrouwbaar voor productie op schaal? Naar onze ervaring ja. We voeren deze setup al meer dan 8 maanden uit met 99,98% uptime. De enige incidenten waren zelf-aangebracht — een slechte implementatie die onze revalidatie-webhook brak, en één Supabase-onderhoudsvenster dat 15 minuten gedegradeerde zoeken veroorzaakte. Vercel's edge cache is rotsvast.

Moet ik Algolia of Typesense voor een grote directory gebruiken? Het hangt van je budget af. Algolia is de industriestandaard met de beste developer experience, maar wordt duur voorbij 100K records — verwacht €500-1000+/maand. Typesense levert 90% van de functionaliteit tegen een fractie van de kosten bij zelfhosting. We kozen Typesense en hebben er geen spijt van.

Hoe houd je 137.000 aanbiedingen up-to-date? We gebruiken een combinatie van benaderingen: on-demand revalidatie geactiveerd door Supabase-webhooks wanneer individuele aanbiedingen veranderen, revalidatie op basis van tijd (elk uur) als vangnet, en een nachtelijke batchtaak die op verouderde gegevens controleert en bulk-revalidatie triggert. Aanbieders kunnen ook handmatig 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 directory-projecten gebouwd waar de redactionele content in een headless CMS staat en de aanbiedingsgegevens in Postgres — het is een geldige hybridebenadering.