Comment nous avons construit une plateforme d'annuaire avec 137K annonces avec Next.js et Vercel ISR
Comment nous avons construit une plateforme d'annuaire avec 137 000 annonces avec Next.js et Vercel ISR
L'année dernière, nous avons lancé une plateforme d'annuaire. 137 000 annonces. Ce n'était pas un petit effort. C'était une plateforme entièrement réalisée où chaque annonce avait sa propre page optimisée pour le SEO. Les recherches devaient être rapides, et oui, l'hébergement devait rester abordable. Alors, comment l'avons-nous fait avec Next.js, Vercel et Incremental Static Regeneration (ISR) ? Accrochez-vous ; voici l'histoire, y compris où les choses se sont compliquées.

Table des matières
- Pourquoi une plateforme d'annuaire est plus difficile qu'il n'y paraît
- Aperçu de l'architecture
- La stratégie ISR qui fonctionne réellement à l'échelle
- Gestion de 137 000 pages sans exploser les temps de construction
- Couche de base de données et de recherche
- SEO à l'échelle : Sitemaps, données structurées et budget de crawl
- Benchmarks de performance
- Ventilation des coûts sur Vercel
- Les erreurs que nous avons commises et ce que nous changerions
- FAQ
Pourquoi une plateforme d'annuaire est plus difficile qu'il n'y paraît
Les sites d'annuaires peuvent sembler simples. Vous pourriez penser une page de liste, une page de détail, quelques filtres, et voilà ! Terminé. Mais une fois que vous dépassez quelques milliers d'annonces, tout devient complexe.
Voici ce qui se passe vraiment :
- 137 000+ pages uniques qui doivent chacune être crawlables et indexables
- Recherche à facettes par localisation, catégorie, et plus
- Gestion des données obsolètes -- les annonces sont en perpétuel changement avec des mises à jour et des suppressions
- Les exigences SEO signifient que vous ne pouvez pas simplement compter sur le rendu côté client
- L'hébergement avec un budget élimine la génération de toutes les pages au moment de la construction
Après avoir examiné un tas de méthodes, nous avons opté pour Next.js avec ISR. Nous avons également envisagé Astro (utilisé dans certains de nos autres projets--voir notre travail de développement Astro). En fin de compte, la capacité dynamique de Next.js avec ISR était évident.
Aperçu de l'architecture
Voici à quoi ressemble notre architecture :
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Vercel │────▶│ Next.js App │────▶│ PostgreSQL │
│ Edge CDN │ │ (ISR) │ │ (Neon) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Redis │ │ Meilisearch │
│ (Upstash) │ │ (Cloud) │
└──────────────┘ └──────────────┘
La pile technologique
| Composant | Technologie | Raison |
|---|---|---|
| Framework | Next.js 14 (App Router) | Support ISR, React Server Components, route handlers |
| Hébergement | Vercel Pro | Edge CDN, infrastructure ISR, analytics |
| Base de données | Neon PostgreSQL | Postgres serverless, branching pour les aperçus |
| Recherche | Meilisearch Cloud | Tolérance aux fautes de frappe, recherche à facettes, indexation rapide |
| Cache | Upstash Redis | Limitation de débit, cache de session, coordination ISR |
| CMS (admin) | Admin personnalisé + Payload CMS | Gestion des annonces, opérations en masse |
| CDN/Images | Optimisation d'images Vercel + Cloudinary | Photos d'annonces à plusieurs points de rupture |
C'est un projet de développement Next.js à la base, et ISR était le grand atout pour nous.

La stratégie ISR qui fonctionne réellement à l'échelle
Allons droit au but : si vous tentez de générer statiquement 137 000 pages au moment de la construction, vous vous posez problème. Sérieusement, n'invitez pas ce mal de tête. Même avec la génération parallèle de Next.js, les constructions pourraient s'étendre au-delà de 45 minutes, transformant chaque déploiement en cauchemar.
ISR vous permet de générer des pages selon les besoins et de les mettre en cache à la périphérie. ISR par défaut est super, mais pour nous, des ajustements étaient essentiels.
La stratégie à trois niveaux
Nous avons divisé nos annonces en trois niveaux :
// app/listing/[slug]/page.tsx
export async function generateStaticParams() {
// Tier 1 : Pré-générer les 2 000 annonces avec le plus de trafic
const topListings = await db.listing.findMany({
where: { tier: 'premium' },
orderBy: { monthlyViews: 'desc' },
take: 2000,
select: { slug: true },
});
return topListings.map((listing) => ({
slug: listing.slug,
}));
}
// Tier 2 & 3 : Générés à la demande via ISR
export const revalidate = 3600; // 1 heure pour la plupart des annonces
export default async function ListingPage({ params }: { params: { slug: string } }) {
const listing = await getListingBySlug(params.slug);
if (!listing) {
notFound();
}
// Revalidation dynamique basée sur le niveau d'annonce
// Les annonces premium se revalidident toutes les 10 minutes
// Les annonces standard toutes les heures
// Les annonces archivées toutes les 24 heures
return <ListingDetail listing={listing} />;
}
Tier 1 (2 000 pages) : Ces annonces à fort trafic sont pré-générées au moment de la construction. Elles sont responsables de la majorité du trafic de recherche organique. Elles sont toujours prêtes.
Tier 2 (35 000 pages) : Générées lors de la première demande, mises en cache pendant une heure. Ces annonces ont un trafic régulier, donc le premier visiteur après l'expiration du cache obtient une page rendue par le serveur mais rapide. Tout le monde d'autre obtient la version mise en cache.
Tier 3 (100 000 pages) : Générées à la première demande, mises en cache pendant 24 heures. Ces annonces reçoivent à peine du trafic, donc il n'y a pas lieu de gaspiller des ressources.
Revalidation à la demande pour les mises à jour en temps réel
La plupart des cas sont couverts par les revalidations chronométrées, mais que se passe-t-il si ce propriétaire de restaurant vient de mettre à jour ses heures ? Eh bien, nous avons déployé la revalidation à la demande de Next.js en utilisant les route handlers :
// 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() });
}
Avec notre panneau d'administration et nos webhooks communiquant avec ce endpoint, tout changement de liste obtient une page fraîche à la prochaine demande. Rapide, non ?
Gestion de 137 000 pages sans exploser les temps de construction
Les temps de construction nous ont honnêtement fait peur ! Voici ce que nous avons découvert :
| Stratégie | Temps de construction | Latence de première demande | Latence de cache hit |
|---|---|---|---|
| SSG complet (toutes les 137 000 pages) | ~52 minutes | ~40ms | ~40ms |
| ISR (2 000 pré-construites) | ~3,5 minutes | ~180ms (cold) | ~40ms |
| SSR complet (pas de cache) | ~45 secondes | ~250ms | N/A |
| Notre approche hybride | ~3,5 minutes | ~150ms (cold) | ~35ms |
Notre approche ISR a réduit les temps de construction d'une heure agoniante à un peu moins de 4 minutes. C'est la différence entre redouter les déploiements et, eh bien, boire un café pendant qu'ils s'exécutent.
Le paramètre `dynamicParams`
Voici un détail crucial : gardez dynamicParams = true pour permettre à ISR de générer des pages en dehors de generateStaticParams. Cela semble évident, mais vous seriez choqué de voir à quel point c'est souvent oublié.
export const dynamicParams = true; // Permettre la génération à la demande
Segments de routes parallèles
Pour les pages avec catégories et localisations, nous avons exploité les segments de routes parallèles pour que le filtre et les grilles d'annonces puissent se charger indépendamment :
// 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>
);
}
Cela signifie que vos filtres peuvent être mis en cache par eux-mêmes. Modifiez un filtre, et seule la grille d'annonces se réaffiche. Rapide !
Couche de base de données et de recherche
PostgreSQL sur Neon
Nous avons choisi Neon pour ses avantages serverless comme la mise à l'échelle et les branches d'aperçu. Le genre de truc qui nous a facilité la vie.
Notre table d'annonces est simple mais repose fortement sur l'indexation :
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);
Pourquoi l'index GiST sur la localisation ? C'est tout sur ces requêtes géospatiales précises. "Cafés près de moi" n'est pas juste du remplissage ; c'est un vrai calcul.
Meilisearch pour la recherche
Si votre liste s'enfle comme la nôtre, la recherche textuelle de PostgreSQL ne suffira pas, et c'est là que Meilisearch intervient. Il nous a battus Algolia, principalement sur le prix (30 $/mois contre 200 $+) et sa tolérance impressionnante aux fautes de frappe.
// 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'],
});
}
Toutes les cinq minutes, les annonces se synchronisent avec un travail. Nous faisons un réindexage complet chaque semaine juste au cas où. Mieux vaut être prudent, non ?
SEO à l'échelle : Sitemaps, données structurées et budget de crawl
Pour une plateforme avec 137 000 pages, le SEO n'est pas juste sympa à avoir ; c'est une question de vie ou de mort. Voici comment nous l'avons réussi :
Sitemaps dynamiques
Vous ne pouvez pas mettre 137 000 URL dans un seul fichier sitemap. La limite est de 50 000 URL selon la spec. Alors, que faisons-nous ? Nous générons un index de sitemap pointant vers des morceaux segmentés :
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Ceci génère l'index de sitemap
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;
}
Les sitemaps segmentés contiennent 10 000 annonces chacun, avec des horodatages. Google explore environ 8 000 à 12 000 pages quotidiennement.
Données structurées
Chaque page d'annonce comprend le balisage de schéma LocalBusiness :
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,
},
};
}
Ce genre de données structurées a considérablement amélioré nos classements, Google donnant de grandes préférences à ces informations précises.
Benchmarks de performance
Les véritables métriques de notre site en direct au début de 2025 :
| Métrique | Valeur | Cible |
|---|---|---|
| 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 (cached) / 190ms (cold ISR) | < 200ms |
| Lighthouse Performance Score | 94-98 | > 90 |
| Temps de construction | 3 min 22 sec | < 5 min |
| Taux de cache hit | 94,7% | > 90% |
Ce taux élevé de cache hit ? Oui, un énorme 94,7% de nos pages proviennent directement du CDN edge de Vercel--aucun calcul supplémentaire nécessaire. C'est un gagnant-gagnant pour la vitesse et les coûts.
Ventilation des coûts sur Vercel
Passons aux dollars et cents. Qui n'aime pas une bonne affaire ?
| Service | Coût mensuel (2025) | Notes |
|---|---|---|
| Vercel Pro | 20 $/siège | Pour les fonctionnalités et les limites de niveau pro |
| Bande passante Vercel | ~55 $ | ~600 Go/mois avec ISR caching |
| Fonctions serverless Vercel | ~40 $ | Pour le travail ISR + stuff API |
| Neon PostgreSQL | 19 $ (plan Scale) | Stockage 10 Go, calcul scalable |
| Meilisearch Cloud | 30 $ | 500 K docs, instance dédiée |
| Upstash Redis | 10 $ | 10 K commandes/jour en moyenne |
| Cloudinary | 25 $ | Stockage et transformations d'images |
| Total | ~199 $/mois | Pour 137 000 pages, ~200 000 visiteurs mensuels |
Moins de 200 $/mois pour exécuter une bête avec 137 000 pages. Par rapport à une configuration de serveur traditionnel ? Vous perdriez beaucoup d'argent sur les VM, les BD gérées, les CDN et un DevOps à temps complet pour en prendre soin.
Si vous jouez à cette échelle et voulez discuter, contactez-nous ou regardez notre tarification.
Les erreurs que nous avons commises et ce que nous changerions
Erreur 1 : Ne pas mettre en place la revalidation à la demande dès le départ
Nous avons initialement compté uniquement sur la revalidation chronométrée. Permettez-moi de vous dire, mauvaise décision. Les propriétaires d'annonces modifieraient leurs informations et vérifieraient instantanément. Voir des données anciennes ? Pas un stimulant de confiance. La revalidation devait être MVP.
Erreur 2 : Sous-estimer la complexité du Sitemap
Notre première tentative de sitemap a entassé tout dans une seule fonction serverless. Cue timeouts. Vercel vous donne 10 secondes (60 on Pro) avant timeout. Nous avons appris. Segmentez ces suckers.
Erreur 3 : Coûts d'optimisation d'images
À l'origine, Vercel gérait toutes les optimisations de photos d'annonces. Une quantité folle d'images signifiait des coûts sauvages. Nous avons divisé ce devoir avec Cloudinary, réservant la magie de Vercel pour les indispensables UI.
Erreur 4 : Ne pas utiliser agressivement les React Server Components
Certaines pages initiales étaient remplies de trop de commandes 'use client'. Résultat ? Trop de JavaScript expédié. En se recentrant sur les Server Components, nous avons rendu notre bundle JavaScript léger comme une plume (réduction de 62 %!).
Ce que nous ferions différemment
La prochaine fois, nous emparerions absolument Next.js avec quelque chose comme un Payload CMS dès le départ au lieu de bricoler un panneau d'administration à partir de zéro. Quel gain de temps cela aurait été !
Nous envisagerions également de près le dernier unstable_cache de Vercel (ou juste cache maintenant) pour les résultats de requête au-delà de la mise en cache ISR standard.
FAQ
Next.js ISR peut-il vraiment gérer des centaines de milliers de pages ?
Absolument. Nous avons marché sur nos paroles. Pré-générez vos pages à fort trafic (généralement 1-5 %) en utilisant generateStaticParams et laissez ISR s'occuper du reste. La périphérie de Vercel prend le relais à partir de là, assurant des temps de chargement rapides dans le monde.
Combien coûte l'exécution d'un grand site d'annuaire sur Vercel ?
Pour nous, c'est environ 199 $/mois pour 137 000 annonces avec 200 000 visiteurs mensuels. Les coûts varieront bien sûr, mais atteignez cette rythme de cache doux, et ISR peut vous faire économiser beaucoup.
Quelle est la différence entre ISR et SSR pour les sites d'annuaire ?
ISR génère les pages une fois par intervalle de revalidation et les met en cache, tandis que SSR génère les pages à partir de zéro à chaque demande. ISR est plus efficace pour les annonces où les données ne changent pas à chaque minute.
Comment gérez-vous la recherche sur un annuaire généré statiquement ?
Les interactions de recherche vont directement à Meilisearch, avec des appels API pour couvrir. Les résultats de recherche sont rendus côté client, tandis que les pages d'annonces sont soutenues par ISR. C'est le meilleur mélange de statique et de dynamique.
Quel intervalle de revalidation dois-je utiliser pour ISR sur un site d'annuaire ?
Cela dépend de la fréquence de changement. Nous utilisons une approche à niveaux : 10 min pour les premiums, 1 heure pour les standards, et 24 heures pour les annonces plus calmes. Saupoudrez la revalidation à la demande pour les changements instantanés.
Comment générez-vous des sitemaps pour 137 000 pages sans timeout ?
La segmentation est votre ami. Découpez-les en morceaux de 10 000. Routez-les via un index de sitemap. Chaque morceau doit rester confortablement dans les limites de timeout.
Next.js est-il le meilleur framework pour construire des plateforme d'annuaire ?
Oui, pour les gros morceaux--surtout avec ISR. Pour les listes ultra-simples et rarement modifiées ? Astro peut être une option légère. Nous avons réalisé les deux ; le choix dépend de votre charge de travail et de vos besoins.
Comment empêchez-vous les données obsolètes de nuire à l'expérience utilisateur avec ISR ?
Mélanger les revalidations basées sur le temps et à la demande aide. Associez cela à SWR côté client ou React Query pour des données ultra-fraîches. ISR alimente votre coquille tandis que le temps réel brille sélectivement.