Aufbau eines globalen Verzeichnisses mit 137K Einträgen mit Next.js, Supabase & Vercel ISR
Letztes Jahr haben wir ein globales Verzeichnis mit 137.000 Einträgen bereitgestellt. Nicht als Prototyp. Nicht als „wir optimieren später" MVP. Ein Produktionssystem, das Millionen von Seitenaufrufen verarbeitet, für Tausende von Long-Tail-Keywords rankt und Seiten bei Bedarf neu generiert, ohne dabei in Schweiß auszubrechen. Dies ist die Geschichte, wie wir es gebaut haben — und die architektonischen Entscheidungen, die es möglich gemacht haben.
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 Mauern gestoßen. Wir haben Dinge umgeschrieben, die wir für fertig hielten. Aber die endgültige Architektur verarbeitet 137.000+ dynamische Seiten mit sub-200ms TTFB weltweit, und unsere Supabase-Rechnung liegt unter $100/Monat.
Wenn du etwas Ähnliches baust — einen Marktplatz, ein Verzeichnis, eine Listingsplattform — dies ist der Artikel, den ich mir zu Beginn gewünscht hätte.
Inhaltsverzeichnis
- Warum dieser Stack
- Die Datenschicht: Supabase im großen Maßstab
- Seitengenerierungsstrategie: ISR, SSG und das 137K-Problem
- URL-Architektur und SEO im großen Maßstab
- Suche und Filterung: Der schwierige Teil
- Performance-Budgets und Edge-Caching
- Überwachung und Observability in der Produktion
- Kostenaufschlüsselung: Was dies wirklich kostet
- Was wir anders machen würden
- FAQ

Warum dieser Stack
Wir haben viele Optionen bewertet, bevor wir uns auf Next.js + Supabase + Vercel geeinigt haben. Die Kernanforderungen waren:
- 137.000+ einzigartige Seiten, die Suchmaschinen crawlen und indexieren können
- Sub-Sekunden-Seitenladevorgänge weltweit (Benutzer in 40+ Ländern)
- Dynamische Daten — Einträge werden täglich aktualisiert, einige stündlich
- Volltextsuche mit facettierter Filterung
- Budgetbewusst — dies war nicht ein VC-finanzierter Mondschuss
Wir haben Astro in Betracht gezogen (großartig für statische Seiten, aber wir brauchten mehr dynamische Interaktivität — obwohl unser Astro-Entwicklungsteam hervorragende Verzeichnisprojekte damit ausgeliefert hat). Wir haben uns WordPress + WPEngine angeschaut. Wir haben kurzzeitig eine reine SPA mit Algolia in Betracht gezogen.
Next.js gewann wegen einer Killer-Funktion: Incremental Static Regeneration. ISR bedeutete, dass wir nicht zwischen statischer Performance und dynamischem Inhalt wählen mussten. Wir konnten beides haben.
Supabase gewann gegenüber PlanetScale und Neon wegen des vollständigen Pakets — Auth, Storage, Edge Functions und eine wirklich gute Postgres-Implementierung mit Row Level Security. Für ein Verzeichnis braucht man all das.
Vercel war das Bereitstellungsziel, da ISR am besten auf Vercel funktioniert (wenig überraschend). Die Integration ist native. On-Demand-Revalidierung funktioniert einfach.
Was ist mit Self-Hosting?
Wir haben ein selbst gehostetes Next.js-Setup auf Railway prototypisiert. Es funktionierte, aber ISR auf selbst gehostetem Next.js hat Eigenheiten. Die Cache-Ungültig-Machungs-Geschichte ist schlimmer. Du musst deine eigene CDN-Schicht verwalten. Für ein Team von 3 Ingenieuren war der operative Overhead nicht wert die $200/Monat, die wir sparen würden.
Die Datenschicht: Supabase im großen Maßstab
Unsere Supabase-Datenbank enthält 137.000 Einträge, jeweils mit 40-60 Feldern. Kategorien, Orte, Kontaktinformationen, umfangreiche Beschreibungen, Bilder, Bewertungen, Öffnungszeiten — alles.
Schema-Design
Die größte Entscheidung war, ob man ein normalisiertes relationales Schema oder einen dokumentenorientierten Ansatz mit JSONB-Spalten verwendet. Wir wählten einen hybriden Ansatz:
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 Relationsdaten für Dinge, die wir filtern (Kategorien, Städte, Länder). JSONB für halbstrukturierte Daten, die pro Eintrag variieren (Kontaktmethoden, benutzerdefinierte Attribute, Media-Arrays). Dies gab uns das Beste aus beiden Welten — schnelle indizierte Abfragen bei relationalen Spalten und Flexibilität bei den restlichen.
Der Such-Vektor
Diese 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;
Dies bedeutet, dass jeder Eintrag durch Postgres selbst vollständig durchsuchbar ist. Kein externer Suchdienst erforderlich für die ersten 100K Einträge. Wir werden später darüber sprechen, wann dies nicht mehr funktioniert.
Connection Pooling
Supabase verwendet PgBouncer für Connection Pooling. Mit ISR bekommst du Bursts von Serverless-Funktionsaufrufen — jede benötigt eine Datenbankverbindung. Ohne Pooling wirst du Verbindungen in Minuten erschöpfen.
Wir verwenden die gepoolte Verbindungszeichenfolge (port 6543) für alle Serverless-Kontexte und die direkte Verbindung (port 5432) nur für Migrationen und Admin-Aufgaben. Das ist eine dieser Sachen, die offensichtlich klingen, aber Menschen erwischen.
// 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 }
}
)
Seitengenerierungsstrategie: ISR, SSG und das 137K-Problem
Hier wird es interessant. Und hier haben wir unseren größten frühen Fehler gemacht.
Der naive Ansatz (Mache dies nicht)
Unser erster Versuch: Generiere alle 137.000 Seiten zur Build-Zeit mit generateStaticParams. Der Build dauerte 4 Stunden und 22 Minuten. Vercels kostenlose Stufe hat ein 45-Minuten-Build-Limit. Sogar die Pro-Stufe ist auf 6 Stunden begrenzt. Aber das eigentliche Problem war nicht das Timeout — es war die Feedback-Schleife. Jeder Deploy dauerte einen halben Tag. Das ist nicht praktikabel.
Der ISR-Ansatz (Was tatsächlich funktioniert)
Hier ist die Strategie, die wir bereitgestellt haben:
- Zur Build-Zeit: Generiere die Top-5.000 Seiten (nach Traffic) statisch
- Bei erstem Request: Generiere verbleibende Seiten bei Bedarf und cache sie
- Revalidierung: Zeitbasiert (alle 3600 Sekunden) + bei Bedarf 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-Revalidierung
Wenn ein Eintragseigentümer seine Daten aktualisiert, wollen wir nicht bis zu eine Stunde warten, bis die Seite aktualisiert wird. Supabase-Webhooks lösen eine Next.js-API-Route aus:
// 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 })
}
Dies gibt uns das Beste aus beiden Welten: Static-Site-Performance mit Dynamic-Site-Aktualität. Builds sind in unter 8 Minuten fertig. Seiten, die nicht vorab generiert wurden, werden beim ersten Besuch erstellt und am Edge gecacht.
Die Zahlen
| Metrik | Vollständiger SSG (Naiv) | ISR (Produktion) |
|---|---|---|
| Build-Zeit | 4h 22m | 7m 40s |
| Seiten beim Deploy | 137.000 | 5.000 |
| Erster Besuch (ungecacht) | N/A | ~800ms |
| Nachfolgende Besuche | ~120ms | ~120ms |
| Revalidierungs-Latenz | Vollständiger Redeploy | < 2 Sekunden |
| Monatliche Build-Minuten | Weit über dem Limit | ~230 Minuten |

URL-Architektur und SEO im großen Maßstab
Mit 137.000 Seiten ist URL-Struktur nicht eine Nachgedanke — es ist Architektur. Jede URL ist eine Ranking-Gelegenheit.
Die URL-Hierarchie
/ → Startseite
/categories/[category-slug] → Kategorie-Seiten (48 Kategorien)
/locations/[country]/[city] → Ortsseiten
/listing/[listing-slug] → Individueller Eintrag
/search?q=...&category=...&city=... → Suchergebnisse (noindex)
Kategorie + Ortsschnitt-Seiten sind die echte SEO-Goldmine:
/categories/restaurants/us/new-york → „Restaurants in New York"
/categories/hotels/uk/london → „Hotels in London"
Diese Schnitt-Seiten werden dynamisch mit ISR generiert. Es gibt ungefähr 12.000 gültige Kombinationen. Jede zielt auf ein spezifisches Long-Tail-Keyword.
Sitemap-Generierung
Mit 137K URLs brauchst du Sitemap-Index-Dateien. Googles Limit sind 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 Eintrag-Seite enthält JSON-LD-Strukturdaten. 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: PostgreSQL-Volltextsuche
Bei unserem anfänglichen Launch handhabte Postgres tsvector-Suche alles. Es ist schnell genug für 137K Zeilen mit einem GIN-Index. Abfrage-Zeiten im Durchschnitt 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 Einträgen begannen komplexe facettierte Suchen (Kategorie + Ort + Text + Sortieren) 300-500ms zu treffen. Akzeptabel für die meisten Apps, aber unsere Benutzer erwarteten sofortige Ergebnisse.
Wir fügten Typesense als Such-Schicht hinzu. Nicht Algolia (zu teuer für unseren Maßstab — wir würden $500+/Monat zahlen). Nicht Meilisearch (großartig, aber Typesenses Geo-Suche war besser für unseren Anwendungsfall).
Typesense läuft auf einer einzelnen $48/Monat Hetzner-Instanz. Synchronisiert von Supabase via nächtlicher vollständiger Reindexierung + Echtzeit-Webhook-Updates. Suchabfragen durchschnittlich jetzt 8-15ms.
| Such-Lösung | Abfrage-Zeit (p50) | Abfrage-Zeit (p99) | Monatliche Kosten | Facettierte Suche |
|---|---|---|---|---|
| PostgreSQL FTS | 45ms | 320ms | $0 (enthalten) | Begrenzt |
| Typesense | 9ms | 28ms | $48 | Hervorragend |
| Algolia | ~5ms | ~15ms | $500+ | Hervorragend |
| Meilisearch | ~8ms | ~22ms | $48 (selbst gehostet) | Gut |
Performance-Budgets und Edge-Caching
Wir legten aggressiv Performance-Ziele von Anfang an fest:
- TTFB: < 200ms (global p75)
- LCP: < 1,5s
- CLS: < 0,05
- Gesamte Seitengröße: < 300KB (initialer Load)
Vercel Edge-Netzwerk
ISR-Seiten werden im Vercel Edge-Netzwerk gecacht — 100+ PoPs weltweit. Sobald eine Seite generiert und gecacht ist, wird sie vom nächstgelegenen Edge-Ort bedient. Deshalb bleibt TTFB unter 200ms auch für Benutzer in Südostasien oder Südamerika.
Bildoptimierung
Jeder Eintrag hat 1-8 Bilder. Das sind potenziell über eine Million Bilder. Wir verwenden Vercels integrierte Bildoptimierung 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 Bild-CDN bedient. Die ursprünglichen Bilder sind oft 2-5MB; nach Optimierung, sie sind 40-120KB. Dies allein sparte uns ungefähr 80% der Bandbreite.
Überwachung und Observability in der Produktion
Den Betrieb von 137K Seiten in der Produktion ohne Überwachung ist wie fahren mit verbundenen Augen. Hier ist unser Stack:
- Vercel Analytics: Core Web Vitals, echte Benutzerüberwachung
- Sentry: Error-Tracking (wir fangen ~50 Fehler/Tag auf, hauptsächlich von Bots, die Müll senden)
- Supabase Dashboard: Datenbankperformance, Abfrage-Analyse
- Checkly: Synthetische Überwachung, 5-Minuten-Intervalle auf kritischen Pfaden
- Google Search Console: Index-Abdeckung, Crawl-Statistiken
Die wertvollste Überwachung, die wir einrichteten, war eine tägliche Supabase-Abfrage, die indexierte Seiten vs. insgesamt aktive Einträge zählt. Falls das Verhältnis unter 95% fällt, bekommen wir einen Alert. Dies erwischte eine Sitemap-Regression innerhalb von 24 Stunden beim Deploy einer schlecht Änderung.
Kostenaufschlüsselung: Was dies wirklich kostet
Menschen fragen immer nach Kosten. Hier ist der echte monatliche Ausgaben-Stand zum Q1 2025:
| Service | Plan | Monatliche Kosten |
|---|---|---|
| Vercel | Pro | $20 |
| Vercel Bandbreite (Übergebuhr) | Pay-as-you-go | ~$35 |
| Supabase | Pro | $25 |
| Supabase Database (Compute) | Kleine Instanz | $48 |
| Typesense (Hetzner) | CX31 | $48 |
| Checkly | Starter | $7 |
| Sentry | Team | $26 |
| Domain + DNS (Cloudflare) | Kostenlos | $0 |
| Gesamt | ~$209/Monat |
Das Bereitstellen von 137.000 Seiten mit Millionen monatlicher Seitenaufrufe für etwa $200/Monat. Versuche das mit einem traditionellen Server-Setup mit WordPress.
Wenn du ein ähnliches Projekt überlegst und verstehen möchtest, wie eine Architektur wie diese auf dein Budget zutrifft, schlüsselt unsere Pricing-Seite auf, wie wir typischerweise Verzeichnis- und Marketplace-Projekte bewerten.
Was wir anders machen würden
Starte mit ISR von Anfang an. Wir verschwendeten zwei Wochen damit, zu versuchen, vollständigen SSG zum Arbeiten zu bringen, bevor wir die Mathematik akzeptierten, die nicht aufging.
Verwende Typesense von Anfang an. Postgres FTS war früh in Ordnung, aber Suchmigrationen mitten im Projekt waren störend. Die $48/Monat würde sich seit dem Launch rentiert haben.
Investiere früher in Datenvalidierung. Mit 137K Einträgen, die aus verschiedenen Quellen importiert wurden, war Datenqualität ein Alptraum. Wir hätten strengere Zod-Schemas und Validierungs-Pipelines vor dem ersten Import erstellen sollen, nicht nachdem wir Tausende fehlerhafter Records in der Produktion gefunden hatten.
Teste mit realistischen Datenmengen im Staging. Unsere Staging-Umgebung hatte 500 Einträge. Abfragen, die auf 500 Zeilen großartig funktionierten, brachen bei 137K zusammen. Wir seeden jetzt Staging mit einer 20%igen zufälligen Stichprobe von Produktionsdaten.
Wenn du ein Verzeichnis oder Marketplace-Build planst und dieselben Fallstricke vermeiden möchtest, wende dich an unser Team. Wir sind genug Mal durch dies gegangen, um zu wissen, wo die Minen sind.
FAQ
Wie lange dauert es, ein 100K+-Eintragsverzeichnis mit Next.js zu bauen?
Für unser Team dauerten die anfängliche Architektur und Kernfunktionen etwa 10 Wochen. Datenimport, Bereinigung und Validierung fügte weitere 3-4 Wochen hinzu. Gesamtdauer vom Kickoff bis zur Produktionsbereitstellung war ungefähr 14 Wochen. Wenn du mit einem Next.js-Entwicklungsteam arbeitest, das dies zuvor getan hat, kannst du 2-3 Wochen davon abziehen.
Kann Supabase 100.000+ Zeilen für ein Verzeichnis verarbeiten?
Absolut. Supabase läuft auf Postgres, das Millionen von Zeilen ohne Probleme verarbeitet. Der Schlüssel ist richtige Indizierung — ohne Indizes auf deinen am häufigsten abgefragten Spalten, Leistung verschlechtert sich schnell. Mit den oben beschriebenen Indizes liefern unsere Abfragen auf 137K Zeilen konsistent in unter 50ms für Einzeldatensatz-Lookups.
Was ist der Unterschied zwischen ISR und SSG für große Seiten?
SSG (Static Site Generation) erstellt jede Seite zur Deploy-Zeit. ISR (Incremental Static Regeneration) erstellt eine Teilmenge zur Deploy-Zeit und generiert den Rest bei Bedarf. Für Seiten mit mehr als ~10.000 Seiten, ist ISR praktisch erforderlich — vollständiger SSG-Builds wird zu langsam für vernünftige Deployment-Zyklen.
Wie handhabs du SEO für 137.000 dynamisch generierte Seiten?
Drei Dinge sind am wichtigsten: richtige Sitemap-Generierung aufgeteilt über mehrere Dateien, einzigartige Strukturdaten (JSON-LD) auf jeder Eintrag-Seite, und sicherstellen, dass ISR-generierte Seiten richtige HTTP-200-Statuscodes zurückgeben (nicht soft 404s). Wir generieren auch einzigartige Meta-Titel und Beschreibungen pro Seite mit den Eintrag-Daten — keine Duplikat Meta-Inhalte.
Ist Vercel ISR zuverlässig für Produktion im großen Maßstab?
Aus unserer Erfahrung, ja. Wir führen dieses Setup für über 8 Monate mit 99,98% Verfügbarkeit aus. Die einzigen Vorfälle waren selbstverschuldet — ein schlechter Deploy, der unseren Revalidierungs-Webhook brach, und ein Supabase-Wartungsfenster, das 15 Minuten degradierte Suche verursachte. Vercels Edge-Cache ist rock-solid.
Sollte ich Algolia oder Typesense für ein großes Verzeichnis verwenden?
Es hängt von deinem Budget ab. Algolia ist der Industriestandard mit der besten Developer Experience, aber es wird teuer past 100K Records — erwarte $500-1000+/Monat. Typesense liefert 90% der Funktionalität bei einem Bruchteil der Kosten, wenn selbst gehostet. Wir wählten Typesense und bereuten es nicht.
Wie hältst du 137.000 Einträge aktuell?
Wir verwenden eine Kombination von Ansätzen: On-Demand-Revalidierung ausgelöst durch Supabase-Webhooks, wenn einzelne Einträge sich ändern, zeitbasierte ISR-Revalidierung (stündlich) als Sicherheitsnetz, und einen nächtlichen Batch-Job, der nach stale Daten sucht und Massenbulk-Revalidierung auslöst. Eintrag-Eigentümer können auch einen Seiten-Refresh manuell anfordern durch ihr Dashboard.
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 für die Inhaltsverwaltungsseite, aber du wirst wahrscheinlich immer noch eine Datenbank für Suche und komplexe Abfragen brauchen. Wir haben Verzeichnis-Projekte gebaut, wo der Editorial-Inhalt in einem headless CMS lebt und die Eintragsdaten in Postgres leben — das ist ein gültiger Hybrid-Ansatz.