Votre déploiement commence à 23h. Vous regardez le journal de build Vercel défiler 10 000 chemins statiques, puis 50 000, puis s'arrêter quelque part près de 89 000. Six heures plus tard, le build expire. Votre annuaire de 137 000 annonces ne sera pas livré parce que vous avez essayé de pré-rendre tout le contenu au moment du build — une erreur qui nous a coûté 11 jours et un appel client très inconfortable. Nous avons finalement livré un système de production qui sert des millions de pages vues, se classe pour des milliers de mots-clés de longue traîne et régénère les pages à la demande pour 209 $/mois. L'architecture qui l'a rendu possible a nécessité d'abandonner notre instinct de tout pré-rendre, de repenser comment les requêtes Supabase se mettent à l'échelle sous ISR, et une modification de la configuration Vercel qui a réduit les temps de réponse de 340ms. Voici ce qui a vraiment fonctionné.

La pile : Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (hébergement + ISR) et une bonne dose de pragmatisme. Nous avons commis des erreurs. Nous avons heurté des obstacles. Nous avons réécrit des choses que nous pensions terminées. Mais l'architecture finale gère 137 000+ pages dynamiques avec un TTFB inférieur à 200ms à l'échelle mondiale, et notre facture Supabase reste inférieure à 100 $/mois.

Si vous construisez quelque chose de similaire — une marketplace, un annuaire, une plateforme d'annonces — cet article est celui que j'aurais aimé avoir quand nous avons commencé.

Table des matières

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

Pourquoi cette pile

Nous avons évalué de nombreuses options avant de nous décider pour Next.js + Supabase + Vercel. Les exigences principales étaient :

  1. 137 000+ pages uniques que les moteurs de recherche pourraient explorer et indexer
  2. Chargements de pages sub-seconde à l'échelle mondiale (utilisateurs dans 40+ pays)
  3. Données dynamiques — les annonces se mettent à jour quotidiennement, certaines toutes les heures
  4. Recherche en texte intégral avec filtrage à facettes
  5. Conscient du budget — ce n'était pas une fusée financée par capital-risque

Nous avons considéré Astro (excellent pour les sites statiques, mais nous avions besoin d'une plus grande interactivité dynamique — bien que notre équipe de développement Astro ait livré d'excellents projets d'annuaires avec). Nous avons envisagé WordPress + WPEngine. Nous avons brièvement considéré une pure SPA avec Algolia.

Next.js a remporté en raison d'une fonctionnalité clé : la régénération statique incrémentale. ISR signifiait que nous n'avions pas à choisir entre la performance statique et le contenu dynamique. Nous pouvions avoir les deux.

Supabase a remporté PlanetScale et Neon en raison de l'ensemble complet — auth, stockage, fonctions edge et une implémentation genuinely bonne de Postgres avec Row Level Security. Pour un annuaire, vous avez besoin de tout cela.

Vercel a été la cible de déploiement car ISR fonctionne mieux sur Vercel (sans surprise). L'intégration est native. La révalidation à la demande fonctionne simplement.

Et l'auto-hébergement ?

Nous avons prototypé une configuration Next.js auto-hébergée sur Railway. Cela a fonctionné, mais ISR sur Next.js auto-hébergé a des particularités. L'histoire de l'invalidation du cache est pire. Vous devez gérer votre propre couche CDN. Pour une équipe de 3 ingénieurs, la surcharge opérationnelle ne valait pas les 200 $/mois que nous aurions économisés.

La couche de données : Supabase à l'échelle

Notre base de données Supabase contient 137 000 annonces, chacune avec 40-60 champs. Catégories, emplacements, informations de contact, descriptions enrichies, images, évaluations, horaires d'ouverture — le tout.

Conception du schéma

La plus grande décision était de choisir entre un schéma relationnel normalisé ou une approche plus orientée documents avec les colonnes JSONB. Nous avons opté pour un hybride :

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

Données relationnelles structurées pour les choses que nous filtrons (catégories, villes, pays). JSONB pour les données semi-structurées qui varient par annonce (méthodes de contact, attributs personnalisés, tableaux de médias). Cela nous a donné le meilleur des deux mondes — des requêtes indexées rapides sur les colonnes relationnelles et de la flexibilité sur le reste.

Le vecteur de recherche

Cette colonne search_vector est critique. Nous la remplissons avec un déclencheur :

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;

Cela signifie que chaque annonce est searchable en texte intégral via Postgres lui-même. Aucun service de recherche externe nécessaire pour les premiers 100K listings. Nous parlerons de quand cela s'effondre plus tard.

Regroupement de connexions

Supabase utilise PgBouncer pour le regroupement de connexions. Avec ISR, vous obtenez des rafales d'invocations de fonctions serverless — chacune a besoin d'une connexion à la base de données. Sans regroupement, vous épuiserez les connexions en minutes.

Nous utilisons la chaîne de connexion regroupée (port 6543) pour tous les contextes serverless et la connexion directe (port 5432) uniquement pour les migrations et les tâches d'administration. C'est une de ces choses qui semble évidente mais qui attrape les gens.

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

Stratégie de génération de pages : ISR, SSG et le problème des 137K

C'est là que les choses deviennent intéressantes. Et c'est là que nous avons commis notre plus grande erreur précoce.

L'approche naïve (ne faites pas cela)

Notre première tentative : générer les 137 000 pages au moment du build en utilisant generateStaticParams. Le build a pris 4 heures et 22 minutes. Le tier gratuit de Vercel a une limite de build de 45 minutes. Même le tier Pro a un plafond de 6 heures. Mais le vrai problème n'était pas le timeout — c'était la boucle de feedback. Chaque déploiement prenait une demi-journée. C'est impossible à travailler.

L'approche ISR (ce qui fonctionne vraiment)

Voici la stratégie qui a été livrée :

  1. Au moment du build : générer les 5 000 pages supérieures (par trafic) de manière statique
  2. À la première requête : générer les pages restantes à la demande et les mettre en cache
  3. Révalidation : basée sur le temps (chaque 3600 secondes) + à la demande 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} />
}

Révalidation à la demande

Quand un propriétaire d'annonce met à jour ses données, nous ne voulons pas attendre jusqu'à une heure pour que la page s'actualise. Les webhooks Supabase déclenchent une route API Next.js :

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

Ceci nous donne le meilleur des deux mondes : la performance d'un site statique avec la fraîcheur d'un site dynamique. Les builds se terminent en moins de 8 minutes. Les pages qui n'ont pas été pré-générées sont créées à la première visite et mises en cache à l'edge.

Les chiffres

Métrique Full SSG (Naïf) ISR (Production)
Temps de build 4h 22m 7m 40s
Pages au déploiement 137 000 5 000
Première visite (non cachée) N/A ~800ms
Visites suivantes ~120ms ~120ms
Latence de révalidation Redéploiement complet < 2 secondes
Minutes de build mensuelles Bien au-delà de la limite ~230 minutes

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

Architecture d'URL et SEO à l'échelle

Avec 137 000 pages, la structure d'URL n'est pas une réflexion après coup — c'est une architecture. Chaque URL est une opportunité de classement.

La hiérarchie d'URL

/                                    → Page d'accueil
/categories/[category-slug]          → Pages de catégories (48 catégories)
/locations/[country]/[city]          → Pages de localisation
/listing/[listing-slug]              → Annonce individuelle
/search?q=...&category=...&city=...  → Résultats de recherche (noindex)

Les pages d'intersection catégorie + localisation sont la véritable mine d'or du SEO :

/categories/restaurants/us/new-york   → « Restaurants à New York »
/categories/hotels/uk/london          → « Hôtels à Londres »

Ces pages d'intersection sont générées dynamiquement avec ISR. Il existe environ 12 000 combinaisons valides. Chacune cible un mot-clé long spécifique.

Génération de sitemap

Avec 137K URL, vous avez besoin de fichiers d'index de sitemap. La limite de Google est de 50 000 URL par 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' },
  })
}

Nous nous divisons en 4 sitemaps : sitemap-0.xml à sitemap-3.xml, référencés par un index de sitemap. Google Search Console a indexé 98% des URL soumises en 6 semaines.

Données structurées

Chaque page d'annonce inclut des données structurées JSON-LD. Pour un annuaire, le schéma LocalBusiness est critique :

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

Recherche et filtrage : la partie difficile

La recherche est toujours la partie difficile. Toujours.

Phase 1 : Recherche en texte intégral Postgres

Pour notre lancement initial, la recherche tsvector de Postgres gérait tout. C'est suffisamment rapide pour 137K lignes avec un index GIN. Les temps de requête ont en moyenne 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 : Quand Postgres ne suffisait plus

Autour de 80 000 listings, les recherches complexes à facettes (catégorie + localisation + texte + tri) ont commencé à atteindre 300-500ms. Acceptable pour la plupart des applications, mais nos utilisateurs s'attendaient à des résultats instantanés.

Nous avons ajouté Typesense comme couche de recherche. Pas Algolia (trop cher à notre échelle — nous paierions 500+$/mois). Pas Meilisearch (excellent, mais la géo-recherche de Typesense était meilleure pour notre cas d'usage).

Typesense s'exécute sur une seule instance Hetzner de 48 $/mois. Se synchronise avec Supabase via une réindexation complète nocturne + des mises à jour webhook en temps réel. Les requêtes de recherche sont maintenant en moyenne de 8-15ms.

Solution de recherche Temps de requête (p50) Temps de requête (p99) Coût mensuel Recherche à facettes
Postgres FTS 45ms 320ms $0 (inclus) Limitée
Typesense 9ms 28ms $48 Excellente
Algolia ~5ms ~15ms $500+ Excellente
Meilisearch ~8ms ~22ms $48 (auto-hébergé) Bonne

Budgets de performance et mise en cache en edge

Nous avons défini des objectifs de performance agressifs dès le départ :

  • TTFB : < 200ms (p75 global)
  • LCP : < 1,5s
  • CLS : < 0,05
  • Poids total de la page : < 300KB (chargement initial)

Réseau Vercel Edge

Les pages ISR sont mises en cache sur le réseau edge de Vercel — 100+ PoPs mondialement. Une fois qu'une page est générée et mise en cache, elle se serve depuis l'emplacement edge le plus proche. C'est pourquoi TTFB reste inférieur à 200ms même pour les utilisateurs en Asie du Sud-Est ou en Amérique du Sud.

Optimisation d'images

Chaque annonce a 1-8 images. C'est potentiellement plus d'un million d'images. Nous utilisons l'optimisation d'image intégrée de Vercel avec 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}
/>

Les images sont stockées dans Supabase Storage et servies via le CDN d'image de Vercel. Les images originales sont souvent 2-5MB ; après optimisation, elles sont 40-120KB. Cela seul nous a permis d'économiser environ 80% sur la bande passante.

Surveillance et observabilité en production

Exécuter 137K pages en production sans surveillance, c'est comme conduire les yeux fermés. Voici notre pile :

  • Vercel Analytics : Core Web Vitals, surveillance des utilisateurs réels
  • Sentry : Suivi des erreurs (nous capturons ~50 erreurs/jour, principalement de robots envoyant des ordures)
  • Tableau de bord Supabase : Performance de la base de données, analyse des requêtes
  • Checkly : Surveillance synthétique, intervalles de 5 minutes sur les chemins critiques
  • Google Search Console : Couverture d'index, statistiques de crawl

La surveillance la plus précieuse que nous avons mise en place était une requête Supabase quotidienne qui compte les pages indexées par rapport aux annonces actives au total. Si le ratio tombe en dessous de 95%, nous recevons une alerte. Cela a attrapé une régression de sitemap en moins de 24 heures après le déploiement d'un mauvais changement.

Ventilation des coûts : ce que cela coûte vraiment

Les gens posent toujours des questions sur les coûts. Voici les véritables dépenses mensuelles au Q1 2026 :

Service Plan Coût mensuel
Vercel Pro $20
Vercel Bandwidth (surcharges) Pay-as-you-go ~$35
Supabase Pro $25
Supabase Database (calcul) Petite instance $48
Typesense (Hetzner) CX31 $48
Checkly Starter $7
Sentry Team $26
Domaine + DNS (Cloudflare) Tier gratuit $0
Total ~$209/mois

Servir 137 000 pages avec des millions de pages vues mensuelles pour environ 200 $/mois. Essayez de le faire avec une configuration de serveur traditionnel exécutant WordPress.

Si vous envisagez un projet similaire et souhaitez comprendre comment une architecture comme celle-ci se traduit par votre budget, notre page de tarification détaille comment nous évaluons généralement les projets d'annuaires et de marketplaces.

Ce que nous ferions différemment

Commencer avec ISR dès le premier jour. Nous avons gaspillé deux semaines en essayant de faire fonctionner la full SSG avant d'accepter que les mathématiques ne s'additionaient pas.

Utiliser Typesense dès le départ. Postgres FTS était correct au départ, mais migrer la recherche en milieu de projet a été perturbateur. Le 48 $/mois aurait valu le coup dès le lancement.

Investir dans la validation des données plus tôt. Avec 137K listings importés de diverses sources, la qualité des données était un cauchemar. Nous aurions dû construire des schémas Zod plus stricts et des pipelines de validation avant la première importation, pas après avoir trouvé des milliers de records cassés en production.

Testez avec des volumes de données réalistes en staging. Notre environnement de staging avait 500 listings. Les requêtes qui fonctionnaient bien sur 500 lignes se sont effondrées à 137K. Nous seed maintenant le staging avec un échantillon aléatoire de 20% des données de production.

Si vous planifiez une construction d'annuaire ou de marketplace et souhaitez éviter ces mêmes pièges, contactez notre équipe. Nous avons traversé cela assez de fois pour savoir où se trouvent les mines terrestres.

FAQ

Combien de temps faut-il pour construire un annuaire avec 100K+ listings sur Next.js ?

Pour notre équipe, l'architecture initiale et les fonctionnalités principales ont pris environ 10 semaines. L'import, le nettoyage et la validation des données ont ajouté 3-4 semaines supplémentaires. Le délai total de lancement à production était d'environ 14 semaines. Si vous travaillez avec une équipe de développement Next.js qui l'a fait auparavant, vous pouvez économiser 2-3 semaines.

Supabase peut-il gérer 100 000+ lignes pour un annuaire ?

Absolument. Supabase s'exécute sur Postgres, qui gère des millions de lignes sans transpirer. La clé est l'indexation appropriée — sans indexes sur vos colonnes les plus interrogées, les performances se dégradent rapidement. Avec les indexes que nous avons décrits ci-dessus, nos requêtes sur 137K lignes retournent constamment en moins de 50ms pour les recherches de simple enregistrement.

Quelle est la différence entre ISR et SSG pour les grands sites ?

SSG (Static Site Generation) crée chaque page au moment du déploiement. ISR (Incremental Static Regeneration) crée un sous-ensemble au moment du déploiement et génère le reste à la demande. Pour les sites avec plus de ~10 000 pages, ISR est pratiquement requis — les builds full SSG deviennent trop lents pour des cycles de déploiement raisonnables.

Comment gérez-vous le SEO pour 137 000 pages générées dynamiquement ?

Trois choses importent le plus : la génération correcte de sitemap divisée entre plusieurs fichiers, des données structurées uniques (JSON-LD) sur chaque page d'annonce, et s'assurer que les pages générées par ISR retournent les codes de statut HTTP 200 appropriés (pas de soft 404). Nous générons également des titres et descriptions de méta uniques par page en utilisant les données de l'annonce — pas de contenu de méta dupliqué.

ISR de Vercel est-il fiable pour la production à l'échelle ?

Dans notre expérience, oui. Nous exécutons cette configuration depuis plus de 8 mois avec 99,98% de disponibilité. Les seuls incidents étaient auto-infligés — un mauvais déploiement qui a cassé notre webhook de révalidation, et une fenêtre de maintenance Supabase qui a causé 15 minutes de dégradation de la recherche. Le cache edge de Vercel est solide comme le roc.

Dois-je utiliser Algolia ou Typesense pour un grand annuaire ?

Cela dépend de votre budget. Algolia est la norme de l'industrie avec la meilleure expérience développeur, mais cela devient cher au-delà de 100K enregistrements — attendez-vous à $500-1000+/mois. Typesense offre 90% de la fonctionnalité à une fraction du coût quand auto-hébergé. Nous avons choisi Typesense et n'avons pas regretté.

Comment gardez-vous 137 000 annonces à jour ?

Nous utilisons une combinaison d'approches : révalidation à la demande déclenchée par des webhooks Supabase quand les annonces individuelles changent, révalidation ISR basée sur le temps (horaire) comme filet de sécurité, et un travail de lot nocturne qui vérifie les données stales et déclenche la révalidation en masse. Les propriétaires d'annonces peuvent également demander manuellement une actualisation de page via leur tableau de bord.

Cette architecture peut-elle fonctionner avec un CMS headless à la place de Supabase ?

Oui, mais avec des compromis. Une configuration CMS headless comme Sanity ou Contentful fonctionne bien pour la gestion du contenu éditorial, mais vous aurez probablement toujours besoin d'une base de données pour la recherche et les requêtes complexes. Nous avons construit des projets d'annuaires où le contenu éditorial vit dans un CMS headless et les données d'annonces vivent dans Postgres — c'est une approche hybride valide.