Créer un annuaire global de 137K annonces avec Next.js, Supabase & Vercel ISR
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
- Pourquoi cette pile technologique
- La couche de données : Supabase à l'échelle
- Stratégie de génération de pages : ISR, SSG, et le problème des 137K
- Architecture des URL et SEO à l'échelle
- Recherche et filtrage : la partie difficile
- Budgets de performance et mise en cache en edge
- Surveillance et observabilité en production
- Ventilation des coûts : ce que cela coûte vraiment
- Ce que nous ferions différemment
- FAQ

Pourquoi cette pile technologique
Nous avons évalué beaucoup d'options avant d'opter pour Next.js + Supabase + Vercel. Les exigences principales étaient :
- 137 000+ pages uniques que les moteurs de recherche pourraient explorer et indexer
- Chargements de pages sub-seconde mondialement (utilisateurs dans 40+ pays)
- Données dynamiques — les annonces se mettent à jour quotidiennement, certaines horaires
- Recherche full-text avec filtrage par facettes
- 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 :
- À la compile : Générer les 5 000 pages principales (par trafic) de manière statique
- À la première requête : Générer les pages restantes à la demande et les mettre en cache
- 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 |

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.