L'année dernière, nous avons lancé un répertoire mondial avec 137 000 annonces. Pas un prototype. Pas un MVP "nous optimiserons plus tard". Un système de production qui sert des millions de pages vues, classe pour des milliers de mots-clés de longue traîne, et régénère les pages à la demande sans transpirer. Voici l'histoire de la façon dont nous l'avons construit — et les décisions architecturales qui l'ont rendu possible.

La pile technologique : Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (hosting + ISR), et une bonne dose de pragmatisme. Nous avons fait des erreurs. Nous avons heurté des murs. 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 à 200 ms mondialement, et notre facture Supabase reste en dessous de 100 $/mois.

Si vous construisez quelque chose de similaire — une place de marché, un répertoire, 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 technologique

Nous avons évalué beaucoup d'options avant d'opter 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 mondialement (utilisateurs dans 40+ pays)
  3. Données dynamiques — les annonces se mettent à jour quotidiennement, certaines horaires
  4. Recherche full-text avec filtrage par facettes
  5. Conscient des budgets — ce n'était pas un pari financé par VC

Nous avons envisagé 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 de répertoires avec). Nous avons regardé WordPress + WPEngine. Nous avons brièvement envisagé une pure SPA avec Algolia.

Next.js a remporté grâce à une fonctionnalité phare : l'Incremental Static Regeneration. ISR signifiait que nous n'avions pas à choisir entre performance statique et contenu dynamique. Nous pouvions avoir les deux.

Supabase a remporté sur PlanetScale et Neon grâce à l'ensemble complet — authentification, stockage, edge functions, et une implémentation Postgres véritablement bonne avec Row Level Security. Pour un répertoire, vous avez besoin de tout cela.

Vercel était la cible de déploiement car ISR fonctionne mieux sur Vercel (sans surprise). L'intégration est native. La revalidation à 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 économiserions.

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, coordonnées de contact, descriptions riches, images, évaluations, horaires d'ouverture — le tout.

Conception du schéma

La plus grande décision était de savoir s'il fallait utiliser un schéma relationnel normalisé ou une approche plus orientée document avec des colonnes JSONB. Nous avons choisi l'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 sur lesquelles 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 multimédias). Cela nous a donné le meilleur des deux mondes — des requêtes indexées rapides sur les colonnes relationnelles et 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 plein texte recherchable via Postgres lui-même. Aucun service de recherche externe nécessaire pour les 100K premiers listings. Nous parlerons de quand cela se décompose plus tard.

Mise en pool des connexions

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

Nous utilisons la chaîne de connexion en pool (port 6543) pour tous les contextes serverless et la connexion directe (port 5432) uniquement pour les migrations et les tâches administrateur. C'est une de ces choses qui semble évidente mais 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 ici que les choses deviennent intéressantes. Et où nous avons fait notre plus grande erreur initiale.

L'approche naïve (Ne faites pas ça)

Notre première tentative : générer les 137 000 pages à la compile en utilisant generateStaticParams. La compilation a pris 4 heures et 22 minutes. Le niveau gratuit de Vercel a une limite de 45 minutes de compilation. Même le niveau Pro plafonne à 6 heures. Mais le vrai problème n'était pas le timeout — c'était la boucle de rétroaction. Chaque déploiement prenait une demi-journée. C'est irréalisable.

L'approche ISR (Ce qui fonctionne réellement)

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

  1. À la compile : Générer les 5 000 pages principales (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. Revalidation : Basée sur le temps (toutes les 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} />
}

Revalidation à 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 se rafraîchisse. 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 })
}

Cela nous donne le meilleur des deux mondes : la performance des sites statiques avec la fraîcheur des sites dynamiques. Les compilations sont terminées 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 à la périphérie.

Les chiffres

Métrique SSG complet (Naïf) ISR (Production)
Durée de compilation 4h 22m 7m 40s
Pages au déploiement 137 000 5 000
Première visite (non mise en cache) N/A ~800ms
Visites ultérieures ~120ms ~120ms
Latence de revalidation Redéploiement complet < 2 secondes
Minutes de compilation par mois Bien au-delà de la limite ~230 minutes

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

Architecture des URL et SEO à l'échelle

Avec 137 000 pages, la structure des URL n'est pas un détail — c'est de l'architecture. Chaque URL est une opportunité de classement.

La hiérarchie des URL

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

Les pages d'intersection catégorie + emplacement sont la véritable mine d'or 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é spécifique de longue traîne.

Génération du plan du site

Avec 137K URLs, vous avez besoin de fichiers d'index de sitemap. La limite de Google est 50 000 URLs 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 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 URLs soumises dans les 6 semaines.

Données structurées

Chaque page d'annonce inclut des données structurées JSON-LD. Pour un répertoire, 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 full-text Postgres

Pour notre lancement initial, la recherche tsvector Postgres a géré tout. C'est assez rapide pour 137K lignes avec un index GIN. Les temps de requête moyennes étaient 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 n'était pas suffisant

À environ 80 000 annonces, les recherches complexes par facettes (catégorie + emplacement + 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 recherche géographique de Typesense était meilleure pour notre cas d'usage).

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

Solution de recherche Temps de requête (p50) Temps de requête (p99) Coût mensuel Recherche par 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 (global p75)
  • LCP : < 1,5s
  • CLS : < 0,05
  • Poids total de la page : < 300KB (chargement initial)

Réseau edge Vercel

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 est servie à partir de l'emplacement edge le plus proche. C'est pourquoi le TTFB reste en dessous de 200ms même pour les utilisateurs en Asie du Sud-Est ou en Amérique du Sud.

Optimisation des images

Chaque annonce a 1-8 images. Cela représente potentiellement plus d'un million d'images. Nous utilisons l'optimisation d'images 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'images de Vercel. Les images originales font souvent 2-5MB ; après optimisation, elles font 40-120KB. Cela seul a économisé environ 80% de bande passante.

Surveillance et observabilité en production

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

  • Vercel Analytics : Core Web Vitals, surveillance des utilisateurs réels
  • Sentry : Suivi des erreurs (nous attrapons ~50 erreurs/jour, principalement d'une part de bots envoyant des déchets)
  • 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 de l'index, statistiques d'exploration

La surveillance la plus précieuse que nous ayons configurée était une requête Supabase quotidienne qui compte les pages indexées par rapport aux annonces actives totales. Si le ratio tombe en dessous de 95%, nous recevons une alerte. Cela a attrapé une régression de sitemap dans les 24 heures du déploiement d'une mauvaise modification.

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

Les gens demandent toujours les coûts. Voici les véritables dépenses mensuelles à partir de Q1 2025 :

Service Plan Coût mensuel
Vercel Pro 20 $
Bande passante Vercel (dépassements) Paiement à l'utilisation ~35 $
Supabase Pro 25 $
Base de données Supabase (calcul) Petite instance 48 $
Typesense (Hetzner) CX31 48 $
Checkly Starter 7 $
Sentry Team 26 $
Domaine + DNS (Cloudflare) Niveau gratuit 0 $
Total ~209 $/mois

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

Si vous envisagez un projet similaire et souhaitez comprendre comment une architecture comme celle-ci se rapporte à votre budget, notre page de tarification explique comment nous évaluons généralement les projets de répertoires et de places de marché.

Ce que nous ferions différemment

Commencer par ISR dès le premier jour. Nous avons gaspillé deux semaines en essayant de faire fonctionner la SSG complète avant d'accepter que les maths ne collaient pas.

Utiliser Typesense dès le départ. Postgres FTS était correct au début, mais migrer la recherche au milieu du projet a été perturbateur. Les 48 $/mois auraient valu la peine dès le lancement.

Investir plus tôt dans la validation des données. Avec 137K annonces importées 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 d'enregistrements cassés en production.

Tester avec des volumes de données réalistes dans la préproduction. Notre environnement de préproduction avait 500 annonces. Les requêtes qui fonctionnaient bien sur 500 lignes se sont effondrées à 137K. Nous ensemençons maintenant la préproduction avec un échantillon aléatoire de 20% des données de production.

Si vous planifiez une construction de répertoire ou de place de marché 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 répertoire avec 100K+ annonces avec Next.js ? Pour notre équipe, l'architecture initiale et les fonctionnalités principales ont pris environ 10 semaines. L'importation de données, le nettoyage et la validation ont ajouté 3-4 semaines supplémentaires. Le total du coup d'envoi au lancement en production était d'environ 14 semaines. Si vous travaillez avec une équipe de développement Next.js qui l'a déjà fait, vous pouvez économiser 2-3 semaines.

Supabase peut-il gérer 100 000+ lignes pour un répertoire ? Absolument. Supabase s'exécute sur Postgres, qui gère des millions de lignes sans transpirer. La clé est une bonne indexation — sans index sur vos colonnes les plus interrogées, la performance se dégrade rapidement. Avec les index que nous avons décrits ci-dessus, nos requêtes sur 137K lignes retournent de manière cohérente en moins de 50ms pour les recherches d'un seul enregistrement.

Quelle est la différence entre ISR et SSG pour les grands sites ? SSG (Static Site Generation) compile chaque page au moment du déploiement. ISR (Incremental Static Regeneration) compile 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 compilations SSG complètes deviennent trop lentes 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 appropriée de sitemap divisée en plusieurs fichiers, des données structurées uniques (JSON-LD) sur chaque page d'annonce, et l'assurance que les pages générées par ISR retournent des codes de statut HTTP 200 appropriés (pas des 404 logiciels). Nous générons également des titres et descriptions méta uniques par page en utilisant les données d'annonce — pas de contenu méta dupliqué.

ISR de Vercel est-il fiable pour la production à l'échelle ? À notre avis, oui. Nous exécutons cette configuration depuis plus de 8 mois avec 99,98% de disponibilité. Les seuls incidents ont été auto-infligés — un mauvais déploiement qui a cassé notre webhook de revalidation, 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 répertoire ? Cela dépend de votre budget. Algolia est la norme industrielle avec la meilleure expérience développeur, mais devient cher au-delà de 100K enregistrements — attendez-vous à 500-1000+$/mois. Typesense offre 90% des fonctionnalités à une fraction du coût lorsqu'il est auto-hébergé. Nous avons choisi Typesense et ne l'avons pas regretté.

Comment gardez-vous 137 000 annonces à jour ? Nous utilisons une combinaison d'approches : revalidation à la demande déclenchée par les webhooks Supabase quand les annonces individuelles changent, revalidation ISR basée sur le temps (horaire) comme filet de sécurité, et un travail par lot la nuit qui vérifie les données obsolètes et déclenche la revalidation en masse. Les propriétaires d'annonces peuvent également demander manuellement un rafraîchissement des pages via leur tableau de bord.

Cette architecture peut-elle fonctionner avec un CMS headless au lieu de Supabase ? Oui, mais avec des compromis. Une configuration de CMS headless comme Sanity ou Contentful fonctionne bien pour le côté gestion de contenu, mais vous aurez probablement encore besoin d'une base de données pour la recherche et les requêtes complexes. Nous avons créé des projets de répertoires où le contenu éditorial vit dans un CMS headless et les données d'annonce vivent dans Postgres — c'est une approche hybride valide.