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.

Comment nous avons construit une plateforme d'annuaire avec 137 000 annonces avec Next.js et Vercel ISR

Table des matières

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.

Comment nous avons construit une plateforme d'annuaire avec 137 000 annonces avec Next.js et Vercel ISR - architecture

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.