Wie wir eine Verzeichnisplattform mit 137.000 Einträgen mit Next.js und Vercel ISR entwickelt haben

Im letzten Jahr haben wir eine Verzeichnisplattform gestartet. 137.000 Einträge. Das war kein Nebenprojekt. Es war eine vollständig realisierte Plattform, bei der jeder Eintrag eine eigene SEO-optimierte Seite hatte. Suchanfragen mussten blitzschnell sein, und ja, das Hosting musste erschwinglich bleiben. Wie haben wir das mit Next.js, Vercel und Incremental Static Regeneration (ISR) geschafft? Schnall dich an; hier ist die Geschichte, einschließlich der kniffligen Stellen.

Wie wir eine Verzeichnisplattform mit 137.000 Einträgen mit Next.js und Vercel ISR entwickelt haben

Inhaltsverzeichnis

Warum eine Verzeichnisplattform schwieriger ist, als es aussieht

Verzeichnisseiten mögen unkompliziert wirken. Man könnte denken, eine Listenseite, eine Detailseite, ein paar Filter hinzufügen, und voilà! Fertig. Aber sobald du über einige tausend Einträge hinausgegangen bist, wird alles unglaublich komplex.

Hier ist, was wirklich vor sich geht:

  • 137.000+ eindeutige Seiten, die jeweils durchsuchbar und indexierbar sein müssen
  • Facettierte Suche über Standort, Kategorie und mehr
  • Verwaltung veralteter Daten -- Einträge sind in ständiger Bewegung mit Aktualisierungen und Löschungen
  • SEO-Anforderungen bedeuten, dass du dich nicht einfach auf Client-seitiges Rendering verlassen kannst
  • Hosting mit kleinerem Budget schließt das Generieren aller Seiten zum Build-Zeitpunkt aus

Nach der Prüfung verschiedener Methoden haben wir Next.js mit ISR als unsere erste Wahl bestätigt. Wir haben auch Astro in Betracht gezogen (das wir in einigen unserer anderen Projekte verwenden – siehe unsere Astro-Entwicklungsarbeiten). Letztendlich war die dynamische Kapazität von Next.js mit ISR die naheliegende Wahl.

Architektur-Übersicht

Hier sieht unsere Architektur aus:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Vercel      │────▶│  Next.js App │────▶│  PostgreSQL  │
│   Edge CDN    │     │  (ISR)       │     │  (Neon)      │
└──────────────┘     └──────────────┘     └──────────────┘
                            │                     │
                            ▼                     ▼
                     ┌──────────────┐     ┌──────────────┐
                     │  Redis       │     │  Meilisearch │
                     │  (Upstash)   │     │  (Cloud)     │
                     └──────────────┘     └──────────────┘

Der Stack

Komponente Technologie Begründung
Framework Next.js 14 (App Router) ISR-Unterstützung, React Server Components, Route Handler
Hosting Vercel Pro Edge CDN, ISR-Infrastruktur, Analytik
Datenbank Neon PostgreSQL Serverless Postgres, Branching für Vorschauen
Suche Meilisearch Cloud Tippfehlertoleranz, facettierte Suche, schnelle Indizierung
Cache Upstash Redis Rate Limiting, Session-Cache, ISR-Koordination
CMS (Admin) Custom Admin + Payload CMS Eintrags-Verwaltung, Massenoperationen
CDN/Bilder Vercel Image Optimization + Cloudinary Eintragsfotos mit mehreren Auflösungen

Dies ist ein Next.js-Entwicklungs-Projekt im Kern, und ISR war der große Verkaufsargument für uns.

Wie wir eine Verzeichnisplattform mit 137.000 Einträgen mit Next.js und Vercel ISR entwickelt haben - Architektur

Die ISR-Strategie, die im großen Maßstab funktioniert

Kommen wir direkt zur Sache: Wenn du versuchen möchtest, 137.000 Seiten zum Build-Zeitpunkt statisch zu generieren, stellst du dich selbst vor Probleme. Ernsthaft, lade diese Kopfschmerzen nicht ein. Auch mit der parallelen Generierung von Next.js könnten Builds über 45 Minuten andauern und jeden Deployment zu einem Albtraum machen.

ISR ermöglicht es dir, Seiten nach Bedarf zu generieren und sie am Edge zu cachen. Standard-ISR ist großartig, aber für uns waren Anpassungen notwendig.

Die dreistufige Seitenstrategie

Wir unterteilten unsere Einträge in drei Stufen:

// app/listing/[slug]/page.tsx

export async function generateStaticParams() {
  // Stufe 1: Die Top 2.000 Einträge mit dem höchsten Traffic vorgenerieren
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

  return topListings.map((listing) => ({
    slug: listing.slug,
  }));
}

// Stufe 2 & 3: On-Demand über ISR generiert
export const revalidate = 3600; // 1 Stunde für die meisten Einträge

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const listing = await getListingBySlug(params.slug);

  if (!listing) {
    notFound();
  }

  // Dynamische Revalidierung basierend auf Eintrag-Tier
  // Premium-Einträge revalidieren alle 10 Minuten
  // Standard-Einträge jede Stunde
  // Archivierte Einträge alle 24 Stunden

  return <ListingDetail listing={listing} />;
}

Stufe 1 (2.000 Seiten): Diese hochfrequenten Einträge werden zum Build-Zeitpunkt vorgeneriert. Sie sind verantwortlich für den meisten organischen Suchtraffic. Sie sind immer bereit.

Stufe 2 (35.000 Seiten): Wird beim ersten Zugriff generiert, für eine Stunde gecacht. Diese Einträge haben gleichmäßigen Traffic, also bekommt der erste Besucher nach Cache-Ablauf eine Server-gerenderte aber schnelle Seite. Alle anderen bekommen die gecachte Version.

Stufe 3 (100.000 Seiten): Wird beim ersten Zugriff generiert, für 24 Stunden gecacht. Diese Einträge sehen kaum Aktivität, also gibt es keinen Grund, Ressourcen zu verschwenden.

On-Demand Revalidation für Echtzeit-Updates

Die meisten Fälle werden durch zeitgesteuerte Revalidierungen abgedeckt, aber was ist mit der Restaurantinhaberin, die gerade ihre Öffnungszeiten aktualisiert hat? Nun, wir haben Next.js's On-Demand Revalidation mithilfe von Route Handlern eingeführt:

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

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

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`);
    revalidateTag(`listing-${slug}`);
  } else if (type === 'category') {
    revalidateTag(`category-${slug}`);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Mit unserem Admin-Panel und Webhooks, die mit diesem Endpoint kommunizieren, bekommt jeder Eintrag-Änderung eine frische Seite beim nächsten Zugriff. Schnell, nicht wahr?

137.000 Seiten ohne explodierenden Build-Zeiten handhaben

Build-Zeiten haben uns ehrlich gesagt verängstigt! Hier ist, was wir gefunden haben:

Strategie Build-Zeit Erste Anfrage Latenz Cache Hit Latenz
Vollständiger SSG (alle 137K Seiten) ~52 Minuten ~40ms ~40ms
ISR (2K vorgefertigt) ~3,5 Minuten ~180ms (kalt) ~40ms
Vollständiger SSR (kein Caching) ~45 Sekunden ~250ms N/A
Unser Hybrid-Ansatz ~3,5 Minuten ~150ms (kalt) ~35ms

Unser ISR-Ansatz reduzierte Build-Zeiten von quälenden 60 Minuten auf knapp unter 4 Minuten. Das ist der Unterschied zwischen dem Fürchten von Deployments und, naja, Kaffee trinken während sie laufen.

Die `dynamicParams` Einstellung

Hier ein wichtiger Hinweis: Halte dynamicParams = true, um ISR die Generierung von Seiten außerhalb von generateStaticParams zu erlauben. Es klingt offensichtlich, aber du würdest erstaunt sein, wie oft das übersehen wird.

export const dynamicParams = true; // On-Demand Generierung erlauben

Parallele Route-Segmente

Für Seiten mit Kategorien und Standorten nutzten wir parallele Route-Segmente, damit Filter und Listing-Gitter unabhängig laden konnten:

// app/directory/[category]/layout.tsx
export default function CategoryLayout({
  children,
  filters,
}: {
  children: React.ReactNode;
  filters: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[280px_1fr] gap-6">
      <aside>{filters}</aside>
      <main>{children}</main>
    </div>
  );
}

Das bedeutet, deine Filter können separat gecacht werden. Ändere einen Filter, und nur das Listing-Gitter wird neu gerendert. Schnell!

Datenbank und Suchschicht

PostgreSQL auf Neon

Wir wählten Neon wegen seiner serverlosen Vorzüge wie Skalierung und Preview-Branches. Die Art von Dingen, die uns das Leben erleichterten.

Unsere Listings-Tabelle ist unkompliziert, vertraut aber stark auf Indexierung:

CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);

Warum der GiST-Index auf location? Es geht um präzise Geodaten-Abfragen. "Coffeeshops in meiner Nähe" ist nicht nur Gerede; es ist eine echte Berechnung.

Meilisearch für Suche

Wenn deine Liste wie unsere anschwillt, reicht PostgreSQLs Textsuche nicht aus, und da kommt Meilisearch ins Spiel. Es schlug Algolia für uns, hauptsächlich beim Preis ($30/Monat vs. $200+) und seiner beeindruckenden Tippfehlertoleranz.

// lib/search.ts
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_API_KEY!,
});

export async function searchListings(query: string, filters: FilterParams) {
  const index = client.index('listings');

  return index.search(query, {
    filter: buildFilterString(filters),
    facets: ['category', 'city', 'priceRange', 'rating'],
    limit: 24,
    offset: filters.page * 24,
    attributesToHighlight: ['name', 'description'],
  });
}

Alle fünf Minuten synchronisieren sich Einträge mit einem Job. Wir führen wöchentlich eine vollständige Neindizierung durch, nur um sicherzugehen. Besser sicher als sorry, richtig?

SEO im großen Maßstab: Sitemaps, strukturierte Daten und Crawl-Budget

Für eine Plattform mit 137.000 Seiten ist SEO nicht nur nice-to-have; es ist lebenswichtig. Hier ist, wie wir es geschafft haben:

Dynamische Sitemaps

Du kannst nicht 137.000 URLs in eine einzelne Sitemap-Datei packen. Das Limit liegt laut Spezifikation bei 50.000 URLs. Also, was tun wir? Wir generieren einen Sitemap-Index, der auf segmentierte Teile zeigt:

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Dies generiert den Sitemap-Index
  const totalListings = await db.listing.count({ where: { status: 'active' } });
  const sitemapCount = Math.ceil(totalListings / 10000);

  const sitemaps = [];

  for (let i = 0; i < sitemapCount; i++) {
    sitemaps.push({
      url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
      lastModified: new Date(),
    });
  }

  return sitemaps;
}

Segmentierte Sitemaps enthalten 10.000 Einträge, mit Zeitstempel. Google crawlt täglich etwa 8.000-12.000 Seiten.

Strukturierte Daten

Jede Listing-Seite ist vollgepackt mit LocalBusiness-Schema-Markup:

function generateStructuredData(listing: Listing) {
  return {
    '@context': 'https://schema.org',
    '@type': 'LocalBusiness',
    name: listing.name,
    description: listing.description,
    address: {
      '@type': 'PostalAddress',
      streetAddress: listing.address,
      addressLocality: listing.city,
      addressRegion: listing.state,
      postalCode: listing.zip,
    },
    aggregateRating: listing.reviewCount > 0 ? {
      '@type': 'AggregateRating',
      ratingValue: listing.avgRating,
      reviewCount: listing.reviewCount,
    } : undefined,
    geo: {
      '@type': 'GeoCoordinates',
      latitude: listing.lat,
      longitude: listing.lng,
    },
  };
}

Diese Art von strukturierten Daten turboladete unsere Rankings, wobei Google solch präzise Informationen bevorzugt.

Leistungs-Benchmarks

Echte Metriken von unsere Live-Seite ab früh 2025:

Metrik Wert Ziel
Largest Contentful Paint (LCP) 1,1s (p75) < 2,5s
First Input Delay (FID) 12ms (p75) < 100ms
Cumulative Layout Shift (CLS) 0,02 (p75) < 0,1
Time to First Byte (TTFB) 85ms (gecacht) / 190ms (kalter ISR) < 200ms
Lighthouse Performance Score 94-98 > 90
Build-Zeit 3 Min 22 Sek < 5 Min
Cache Hit Rate 94,7% > 90%

Diese hohe Cache-Hit-Rate? Ja, satte 94,7% unserer Seiten kommen direkt aus Vercels Edge CDN – keine zusätzlichen Berechnungen nötig. Ein Gewinn-Gewinn für Geschwindigkeit und Kosten.

Kostenaufschlüsselung auf Vercel

Kommen wir zu Dollar und Cent. Wer liebt nicht ein gutes Schnäppchen?

Service Monatliche Kosten (2025) Notizen
Vercel Pro $20/Sitz Für Pro-Level-Features und Limits
Vercel Bandbreite ~$55 ~600GB/Monat mit ISR-Caching
Vercel Serverless-Funktionen ~$40 Für ISR-Arbeit + API-Sachen
Neon PostgreSQL $19 (Scale-Plan) 10GB Speicher, skalierbare Compute
Meilisearch Cloud $30 500K Docs, dedizierte Instanz
Upstash Redis $10 10K Befehle/Tag im Durchschnitt
Cloudinary $25 Bildspeicher und Transformationen
Gesamt ~$199/Monat Für 137K Seiten, ~200K monatliche Besucher

Unter $200/Monat, um ein Monster mit 137.000 Seiten zu betreiben. Gegenüber einem traditionellen Server-Setup? Du würdest Geld bluten auf VMs, verwalteten DBs, CDNs und einem vollzeitigen DevOps, um alles zu babysitten.

Wenn du in diesem Maßstab spielst und einen Chat möchtest, kontaktiere uns oder schau dir unsere Preisgestaltung an.

Fehler, die wir gemacht haben, und was wir ändern würden

Fehler 1: On-Demand Revalidation nicht von Anfang an einrichten

Wir verließen uns anfangs nur auf zeitgesteuerte Revalidierung. Lass mich dir sagen, schlechter Zug. Eintrag-Inhaber würden ihre Informationen tweaken und sofort checken. Alte Daten sehen? Kein Vertrauensbooster. Revalidierung musste MVP sein.

Fehler 2: Sitemap-Komplexität unterschätzen

Unser erster Versuch einer Sitemap jamte alles in eine Serverless-Funktion. Cue Timeouts. Vercel gibt dir 10 Sekunden (60 auf Pro) vor dem Timeout. Wir haben gelernt. Segmentiere diese Dinger.

Fehler 3: Bildoptimierungs-Kosten

Ursprünglich handhabte Vercel alle Listing-Foto-Optimierungen. Eine verrückte Menge an Bildern bedeutete wilde Kosten. Wir teilten diese Aufgabe mit Cloudinary auf und behielten Vercels Magie für UI-Must-Haves.

Fehler 4: React Server Components nicht aggressiv genug nutzen

Einige anfängliche Seiten waren mit zu vielen 'use client'-Befehlen vollgepackt. Resultat? Zu viel JavaScript versandt. Der Fokus auf Server Components machte unser JavaScript-Bundle leicht wie eine Feder (62% Reduktion!).

Was wir beim nächsten Mal anders machen würden

Beim nächsten Mal würden wir absolut Next.js mit etwas wie Payload CMS von Anfang an koppeln, anstatt von Grund auf einen Admin-Panel zu hacken. Was für eine Zeitersparnis das gewesen wäre!

Wir würden auch Vercels neuesten unstable_cache (oder einfach cache jetzt) für Abfrageergebnisse über das Standard-ISR-Caching hinaus sorgfältig in Betracht ziehen.

Häufig gestellte Fragen

Kann Next.js ISR wirklich Hundertausende von Seiten handhaben?
Absolut. Wir haben es vorgemacht. Generiere deine Top-Traffic-Seiten vorab (üblicherweise 1-5%) mit generateStaticParams und lass ISR um den Rest kümmern. Vercels Edge übernimmt von da an und sorgt für schnelle Ladezeiten global.

Wie viel kostet es, eine große Verzeichnisseite auf Vercel zu betreiben?
Für uns sind es etwa $199/Monat für 137K Listings mit 200.000 monatlichen Besuchern. Die Kosten werden sicherlich variieren, aber wenn du das süße Caching-Tempo triffst, kann ISR dich großartig sparen.

Was ist der Unterschied zwischen ISR und SSR für Verzeichnisseiten?
ISR generiert Seiten einmal pro Revalidierungs-Intervall und cached sie, während SSR Seiten bei jeder Anfrage von Grund auf neu generiert. ISR ist effizienter für Einträge, wo sich Daten nicht jede Minute ändern.

Wie handhabst du die Suche auf einer statisch generierten Verzeichnisseite?
Such-Interaktionen gehen direkt zu Meilisearch, mit API-Aufrufen zum Abdecken. Such-Ergebnisse werden Client-seitig gerendert, während Listing-Seiten ISR-gestützt sind. Es ist die beste Mischung aus statisch und dynamisch.

Welches Revalidierungs-Intervall sollte ich für ISR auf einer Verzeichnisseite verwenden?
Hängt von der Änderungshäufigkeit ab. Wir verwenden einen gestuften Ansatz: 10 Min für Premiums, 1 Stunde für Standards und 24 Stunden für ruhigere Einträge. Würze mit On-Demand Revalidation für sofortige Änderungen.

Wie generierst du Sitemaps für 137.000 Seiten ohne zu timeout?
Segmentierung ist dein Freund. Teile sie in Chunks von 10.000 auf. Leite sie durch einen Sitemap-Index. Jeder Chunk sollte bequem innerhalb der Timeout-Limits bleiben.

Ist Next.js das beste Framework zum Erstellen von Verzeichnisplattformen?
Ja, für Heavy Hitter – besonders mit ISR. Für ultra-einfache, selten wechselnde Listen? Astro kann eine leichte Option sein. Wir haben beide erstellt; die Wahl hängt von deiner Workload und deinen Bedürfnissen ab.

Wie verhinderst du, dass veraltete Daten die Benutzererfahrung mit ISR beeinträchtigen?
Das Mischen von zeitgestützter und On-Demand Revalidierung hilft. Paare das mit Client-seitiger SWR oder React Query für ultra-frische Daten. ISR speist deine Shell, während Real-Time selektiv scheint.