Dein Deploy startet um 23 Uhr. Du beobachtst Vercels Build-Log, das 10.000 statische Pfade scrollt, dann 50.000, dann steckt irgendwo bei 89.000 fest. Sechs Stunden später: Timeout. Dein Verzeichnis mit 137.000 Einträgen wird nicht deployed, weil du alles zur Build-Zeit pre-rendern wolltest — ein Fehler, der uns 11 Tage und ein sehr unangenehmes Kundengespräch kostete. Wir haben schließlich ein Produktionssystem deployed, das Millionen Page Views bedient, für Tausende Long-Tail Keywords rankt und Pages on-demand regeneriert — für 209 Dollar pro Monat. Die Architektur, die das möglich machte, erforderte einen Schritt zurück vom instinktiven Pre-Rendering von allem, ein Umdenken darüber, wie Supabase-Abfragen unter ISR skalieren, und eine Vercel-Config-Änderung, die Response-Zeiten um 340ms reduzierte. Hier ist, was tatsächlich funktioniert hat.

Der Stack: Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (Hosting + ISR) und eine gesunde Portion Pragmatismus. Wir haben Fehler gemacht. Wir sind auf Hindernisse gestoßen. Wir haben Dinge umgeschrieben, die wir für fertig hielten. Aber die finale Architektur bedient 137.000+ dynamische Pages mit sub-200ms TTFB weltweit, und unsere Supabase-Rechnung bleibt unter 100 Dollar pro Monat.

Wenn du etwas Ähnliches baust — einen Marketplace, ein Verzeichnis, eine Listings-Plattform — dann ist dies der Artikel, den ich mir am Anfang gewünscht hätte.

Inhaltsverzeichnis

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

Warum dieser Stack

Wir haben viele Optionen evaluiert, bevor wir uns auf Next.js + Supabase + Vercel einigten. Die Kernforderungen waren:

  1. 137.000+ einzigartige Pages, die Suchmaschinen crawlen und indexieren können
  2. Sub-Sekunden-Seitenladen global (Nutzer in 40+ Ländern)
  3. Dynamische Daten — Listings werden täglich aktualisiert, manche stündlich
  4. Volltextsuche mit facettiertem Filtering
  5. Budgetbewusst — das war kein VC-finanziertes Mondschuss-Projekt

Wir betrachteten Astro (großartig für statische Sites, aber wir brauchten mehr dynamische Interaktivität — allerdings hat unser Astro-Entwicklungs-Team ausgezeichnete Verzeichnis-Projekte damit ausgeliefert). Wir betrachteten WordPress + WPEngine. Wir erwogen kurz ein reines SPA mit Algolia.

Next.js gewann wegen eines Killer-Features: Incremental Static Regeneration. ISR bedeutete, wir mussten nicht zwischen statischer Performance und dynamischem Content wählen. Wir konnten beides haben.

Supabase gewann über PlanetScale und Neon, weil es das ganze Paket bietet — Auth, Storage, Edge Functions und eine wirklich gute Postgres-Implementierung mit Row Level Security. Für ein Verzeichnis brauchst du all das.

Vercel war die Deployment-Zielplattform, weil ISR auf Vercel am besten funktioniert (wenig überraschend). Die Integration ist nativ. On-Demand-Revalidation funktioniert einfach.

Was ist mit Self-Hosting?

Wir prototypisierten ein selbstgehostetes Next.js-Setup auf Railway. Es funktionierte, aber ISR auf selbstgehostetem Next.js hat Macken. Die Cache-Invalidierungs-Story ist schlechter. Du musst deine eigene CDN-Schicht verwalten. Für ein Team von 3 Ingenieuren war der operative Overhead nicht wert der 200 Dollar pro Monat, die wir sparen würden.

Die Datenschicht: Supabase im großen Maßstab

Unsere Supabase-Datenbank enthält 137.000 Listings, jedes mit 40-60 Feldern. Kategorien, Orte, Kontaktinformationen, Rich-Descriptions, Bilder, Bewertungen, Öffnungszeiten — das ganze Arsenal.

Schema-Design

Die größte Entscheidung war, ob wir ein normalisiertes relationales Schema oder einen dokumentorientierten Ansatz mit JSONB-Spalten verwenden sollten. Wir gingen hybrid vor:

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

Strukturierte Relationaldaten für Dinge, auf die wir filtern (Kategorien, Städte, Länder). JSONB für semi-strukturierte Dinge, die pro Listing variieren (Kontaktmethoden, benutzerdefinierte Attribute, Media-Arrays). Dies gab uns das Beste aus beiden Welten — schnelle indexierte Abfragen auf den relationalen Spalten und Flexibilität auf den Rest.

Der Such-Vektor

Die search_vector-Spalte ist kritisch. Wir füllen sie mit einem 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;

Das bedeutet, dass jedes Listing über Postgres selbst vollständig durchsuchbar ist. Kein externer Search-Service notwendig für die ersten 100K Listings. Wir reden später darüber, wann das zusammenbricht.

Connection Pooling

Supabase verwendet PgBouncer für Connection Pooling. Mit ISR bekommst du Bursts von Serverless-Function-Aufrufen — jeder benötigt eine Datenbankverbindung. Ohne Pooling erschöpfst du Verbindungen in Minuten.

Wir verwenden den Pooled-Connection-String (port 6543) für alle Serverless-Kontexte und die direkte Verbindung (port 5432) nur für Migrations und Admin-Tasks. Das ist eines dieser Dinge, die offensichtlich klingen, aber Leute abfangen.

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

Page-Generierungsstrategie: ISR, SSG und das 137K-Problem

Hier wird es interessant. Und hier machten wir unseren größten frühen Fehler.

Der naive Ansatz (Nicht tun!)

Unser erster Versuch: Alle 137.000 Pages zur Build-Zeit mit generateStaticParams generieren. Der Build dauerte 4 Stunden und 22 Minuten. Vercels kostenloser Tier hat ein 45-Minuten-Build-Limit. Selbst der Pro-Tier ist auf 6 Stunden begrenzt. Aber das echte Problem war nicht das Timeout — es war die Feedback-Schleife. Jeder Deploy dauerte einen halben Tag. Das ist nicht zu handhaben.

Der ISR-Ansatz (Was tatsächlich funktioniert)

Hier ist die Strategie, die wir shipped haben:

  1. Zur Build-Zeit: Generiere die Top-5.000 Pages (nach Traffic) statisch
  2. Bei erstem Request: Generiere verbleibende Pages on-Demand und cache sie
  3. Revalidation: Zeitbasiert (alle 3600 Sekunden) + 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-Revalidation

Wenn ein Listing-Besitzer seine Daten aktualisiert, wollen wir nicht bis zu einer Stunde auf die Page-Aktualisierung warten. Supabase-Webhooks triggern eine 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 })
}

Das gibt uns das Beste aus beiden Welten: Static-Site-Performance mit Dynamic-Site-Frische. Builds werden in unter 8 Minuten abgeschlossen. Pages, die nicht pre-generiert wurden, werden beim ersten Besuch erstellt und am Edge gecacht.

Die Zahlen

Metrik Full SSG (Naiv) ISR (Produktion)
Build-Zeit 4h 22m 7m 40s
Pages bei Deploy 137.000 5.000
Erster Visit (ungecacht) N/A ~800ms
Nachfolgende Visits ~120ms ~120ms
Revalidierungs-Latenz Vollständiger Redeploy < 2 Sekunden
Monatliche Build-Minuten Deutlich über Limit ~230 Minuten

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

URL-Architektur und SEO im großen Maßstab

Mit 137.000 Pages ist URL-Struktur keine Nachgedanke — es ist Architektur. Jede URL ist eine Ranking-Gelegenheit.

Die URL-Hierarchie

/                                    → Homepage
/categories/[category-slug]          → Category Pages (48 Kategorien)
/locations/[country]/[city]          → Location Pages
/listing/[listing-slug]              → Einzelnes Listing
/search?q=...&category=...&city=...  → Suchergebnisse (noindex)

Kategorie + Ortsschnitt-Pages sind die wahren SEO-Goldminen:

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

Diese Schnitt-Pages werden dynamisch mit ISR generiert. Es gibt etwa 12.000 gültige Kombinationen. Jede targetiert ein spezifisches Long-Tail-Keyword.

Sitemap-Generierung

Mit 137K URLs brauchst du Sitemap-Index-Dateien. Googles Limit ist 50.000 URLs pro 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' },
  })
}

Wir teilen in 4 Sitemaps auf: sitemap-0.xml bis sitemap-3.xml, referenziert durch einen Sitemap-Index. Google Search Console indexierte 98% der eingereichten URLs innerhalb von 6 Wochen.

Strukturierte Daten

Jede Listing-Page beinhaltet JSON-LD strukturierte Daten. Für ein Verzeichnis ist LocalBusiness-Schema kritisch:

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,
}

Suche und Filterung: Der schwierige Teil

Suche ist immer der schwierige Teil. Immer.

Phase 1: Postgres Volltextsuche

Beim initialen Launch handhabte Postgres tsvector Suche alles. Es ist schnell genug für 137K Zeilen mit einem GIN-Index. Abfrage-Zeiten waren durchschnittlich 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)

Phase 2: Wenn Postgres nicht genug war

Bei etwa 80.000 Listings begannen komplexe facettierte Suchen (Kategorie + Ort + Text + Sortierung) 300-500ms zu treffen. Akzeptabel für die meisten Apps, aber unsere Nutzer erwarteten Instant-Ergebnisse.

Wir ergänzten Typesense als Such-Layer. Nicht Algolia (zu teuer auf unserer Skala — wir würden 500+ Dollar pro Monat zahlen). Nicht Meilisearch (großartig, aber Typesenses Geo-Suche war besser für unseren Usecase).

Typesense läuft auf einer einzelnen 48 Dollar pro Monat Hetzner-Instanz. Synchronisiert von Supabase via nächtliche Vollre-Indexierung + Real-Time-Webhook-Updates. Abfragen-Zeiten sind jetzt durchschnittlich 8-15ms.

Such-Lösung Abfrage-Zeit (p50) Abfrage-Zeit (p99) Monatliche Kosten Facettierte Suche
Postgres FTS 45ms 320ms $0 (Inbegriffen) Limitiert
Typesense 9ms 28ms $48 Ausgezeichnet
Algolia ~5ms ~15ms $500+ Ausgezeichnet
Meilisearch ~8ms ~22ms $48 (selbstgehostet) Gut

Performance-Budgets und Edge Caching

Wir setzten aggressive Performance-Ziele von Anfang an:

  • TTFB: < 200ms (global p75)
  • LCP: < 1.5s
  • CLS: < 0.05
  • Gesamtseitengröße: < 300KB (Initial Load)

Vercel Edge Network

ISR-Pages werden im Vercel Edge Network gecacht — 100+ PoPs weltweit. Sobald eine Page generiert und gecacht ist, wird sie vom nächsten Edge-Standort bedient. Deshalb bleibt TTFB unter 200ms, auch für Nutzer in Südostasien oder Südamerika.

Bildoptimierung

Jedes Listing hat 1-8 Bilder. Das sind potenziell über eine Million Bilder. Wir verwenden die integrierte Bildoptimierung von Vercel mit 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}
/>

Bilder werden in Supabase Storage gespeichert und durch Vercels Image-CDN bedient. Die Originalbilder sind oft 2-5MB; nach Optimierung sind sie 40-120KB. Allein das sparte uns etwa 80% Bandbreite.

Monitoring und Observability in der Produktion

137K Pages in Produktion ohne Monitoring zu betreiben ist wie geblendet fahren. Hier ist unser Stack:

  • Vercel Analytics: Core Web Vitals, Real User Monitoring
  • Sentry: Error Tracking (wir fangen ~50 Errors/Tag auf, meist von Bots, die Müll senden)
  • Supabase Dashboard: Database Performance, Query Analysis
  • Checkly: Synthetic Monitoring, 5-Minuten-Intervalle auf kritischen Pfaden
  • Google Search Console: Index Coverage, Crawl Stats

Das wertvollste Monitoring, das wir aufbauten, war eine tägliche Supabase-Abfrage, die indexierte Pages vs. gesamt aktive Listings zählt. Wenn das Verhältnis unter 95% fällt, bekommen wir einen Alert. Das fing eine Sitemap-Regression innerhalb von 24 Stunden nach einem schlechten Deploy.

Kostenaufschlüsselung: Was das tatsächlich kostet

Leute fragen immer nach Kosten. Hier ist die echte monatliche Ausgabe ab Q1 2026:

Service Plan Monatliche Kosten
Vercel Pro $20
Vercel Bandbreite (Überkosten) Pay-as-you-go ~$35
Supabase Pro $25
Supabase Database (Compute) Small Instance $48
Typesense (Hetzner) CX31 $48
Checkly Starter $7
Sentry Team $26
Domain + DNS (Cloudflare) Kostenlos $0
Gesamt ~$209/Monat

137.000 Pages mit Millionen monatlichen Page Views für etwa 200 Dollar pro Monat bedienen. Versuche das mit einem traditionellen WordPress-Server-Setup.

Wenn du ein ähnliches Projekt erwägst und verstehen möchtest, wie eine Architektur wie diese auf dein Budget abgebildet wird, teilt unsere Pricing-Seite auf, wie wir typischerweise Verzeichnis- und Marketplace-Projekte scopieren.

Was wir anders machen würden

Starte mit ISR vom ersten Tag an. Wir verschwendeten zwei Wochen damit, versucht zu machen, dass Full SSG funktioniert, bevor wir die Mathematik nicht aufging akzeptierten.

Benutze Typesense von Anfang an. Postgres FTS war früh ok, aber die Such-Migration mid-Project zu verwirklichen war disruptiv. Die 48 Dollar pro Monat hätten sich vom Launch lohnen sollen.

Investiere früher in Daten-Validierung. Mit 137K Listings aus verschiedenen Quellen importiert, war Datenqualität ein Albtraum. Wir hätten strengere Zod-Schemas und Validierungs-Pipelines vor dem ersten Import bauen sollen, nicht nachdem wir Tausende kaputte Einträge in Production fanden.

Teste mit realistischen Datenvolumen in Staging. Unsere Staging-Umgebung hatte 500 Listings. Abfragen, die auf 500 Zeilen großartig funktioniert, fielen bei 137K auseinander. Wir seeden jetzt Staging mit einer zufälligen 20%-Stichprobe von Produktionsdaten.

Wenn du ein Verzeichnis- oder Marketplace-Build planst und diese selben Fallstricke vermeiden möchtest, wende dich an unser Team. Wir sind oft genug durch das durchgegangen, um zu wissen, wo die Landminen sind.

FAQ

Wie lange dauert es, ein 100K+ Listings-Verzeichnis mit Next.js zu bauen?

Für unser Team hat die initiale Architektur und Kernfunktionen etwa 10 Wochen gedauert. Daten-Import, Bereinigung und Validierung addierte weitere 3-4 Wochen. Die Gesamtzeit vom Kickoff zum Production-Launch war etwa 14 Wochen. Wenn du mit einem Next.js-Entwicklungs-Team arbeitest, das das schon gemacht hat, kannst du 2-3 Wochen abhacken.

Kann Supabase 100.000+ Zeilen für ein Verzeichnis handhaben?

Abs. Supabase läuft auf Postgres, das Millionen Zeilen ohne Murren handhabt. Der Schlüssel ist richtiges Indexing — ohne Indexes auf deinen am häufigsten abfragten Spalten degradiert Performance schnell. Mit den Indexes, die wir oben beschrieben, sind unsere Abfragen auf 137K Zeilen konsistent unter 50ms für Single-Record-Lookups.

Was ist der Unterschied zwischen ISR und SSG für große Sites?

SSG (Static Site Generation) baut jede Page zur Deploy-Zeit. ISR (Incremental Static Regeneration) baut zur Deploy-Zeit eine Teilmenge auf und generiert den Rest On-Demand. Für Sites mit mehr als ~10.000 Pages ist ISR praktisch erforderlich — Full-SSG-Builds werden zu langsam für zumutbare Deployment-Zyklen.

Wie handhabst du SEO für 137.000 dynamisch generierte Pages?

Drei Dinge sind am wichtigsten: Korrekte Sitemap-Generierung aufgeteilt über mehrere Dateien, einzigartige strukturierte Daten (JSON-LD) auf jeder Listing-Page und sicherstellen, dass ISR-generierte Pages richtige HTTP 200 Status-Codes zurückgeben (nicht soft 404s). Wir generieren auch einzigartige Meta-Titel und Beschreibungen pro Page mit Listing-Daten — kein doppelter Meta-Content.

Ist Vercel ISR zuverlässig für Production im großen Maßstab?

Nach unserer Erfahrung ja. Wir haben dieses Setup über 8 Monate mit 99.98% Uptime betrieben. Die einzigen Incidents waren selbstverursacht — ein schlechter Deploy, der unseren Revalidierungs-Webhook brach, und ein Supabase-Wartungsfenster, das 15 Minuten degradierte Suche verursachte. Vercels Edge-Cache ist rocksolid.

Sollte ich Algolia oder Typesense für ein großes Verzeichnis verwenden?

Es hängt von deinem Budget ab. Algolia ist der Industrie-Standard mit bester Developer Experience, aber es wird teuer vorbei 100K Records — erwarte 500-1000+ Dollar pro Monat. Typesense liefert 90% der Funktionalität zu einem Bruchteil der Kosten, wenn selbstgehostet. Wir wählten Typesense und bereuen es nicht.

Wie hältst du 137.000 Listings auf dem Laufenden?

Wir verwenden eine Kombination von Ansätzen: On-Demand-Revalidierung ausgelöst durch Supabase-Webhooks, wenn einzelne Listings sich ändern, zeitbasierte ISR-Revalidierung (stündlich) als Sicherheitsnetz und ein nächtlicher Batch-Job, der nach veralteten Daten prüft und Bulk-Revalidierung triggert. Listing-Besitzer können auch manuell eine Page-Aktualisierung durch ihr Dashboard anfordern.

Kann diese Architektur mit einem Headless-CMS statt Supabase funktionieren?

Ja, aber mit Trade-offs. Ein Headless-CMS-Setup wie Sanity oder Contentful funktioniert gut auf der Content-Management-Seite, aber du brauchst wahrscheinlich immer noch eine Datenbank für Suche und komplexe Abfragen. Wir bauten Verzeichnis-Projekte, wo redaktionaler Content in einem Headless CMS lebt und Listing-Daten in Postgres lebt — es ist ein gültiger Hybrid-Ansatz.