137K Listings auf Next.js ISR + Supabase: Ohne Budget-Explosion
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
- Warum dieser Stack
- Die Datenschicht: Supabase im großen Maßstab
- Page-Generierungsstrategie: 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
- Monitoring und Observability in der Produktion
- Kostenaufschlüsselung: Was das tatsächlich kostet
- Was wir anders machen würden
- FAQ

Warum dieser Stack
Wir haben viele Optionen evaluiert, bevor wir uns auf Next.js + Supabase + Vercel einigten. Die Kernforderungen waren:
- 137.000+ einzigartige Pages, die Suchmaschinen crawlen und indexieren können
- Sub-Sekunden-Seitenladen global (Nutzer in 40+ Ländern)
- Dynamische Daten — Listings werden täglich aktualisiert, manche stündlich
- Volltextsuche mit facettiertem Filtering
- 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:
- Zur Build-Zeit: Generiere die Top-5.000 Pages (nach Traffic) statisch
- Bei erstem Request: Generiere verbleibende Pages on-Demand und cache sie
- 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 |

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.