how-i-built-25000-page-directory-nextjs.html
< BACK Image héro pour « How I Built a 25,000-Page Directory in Next.js: HostList Post-Mortem »

Comment j'ai construit un répertoire de 25 000 pages dans Next.js : HostList Post-Mortem

Fin 2022, je me suis convaincu que construire un répertoire d'hébergement web serait simple. Agréger les données, générer les pages, classer, monétiser. Propre. J'avais déjà fait du SEO programmatique avant -- un outil immobilier localisé pour un client au Royaume-Uni, un site de comparaison SaaS qui a atteint 40k visites mensuelles -- donc je pensais que HostList serait un projet de six semaines. Ça a pris près de sept mois. Et ça a presque cassé quelques choses : mon emploi du temps de sommeil, la confiance d'un de mes développeurs juniors, et une facture Vercel de 180 £/mois que je n'avais pas budgétisée.Vercel bill I hadn't budgeted for.

C'est le post-mortem. Le vrai, pas la version LinkedIn.

---

Le cahier des charges que je me suis écrit

HostList était censé être simple. Un répertoire de fournisseurs d'hébergement web -- partagé, VPS, dédié, WordPress managé -- avec des pages individuelles pour chaque fournisseur, des pages de comparaison, des pages de catégories, et des pages basées sur la localisation (par ex. « meilleur hébergement en Allemagne »). Faites le calcul : ~400 fournisseurs × plusieurs types de pages × 20+ combinaisons de filtres. Vous arrivez à 25 000 pages plus vite que vous le penseriez.WordPress -- with individual pages for each provider, comparison pages, category pages, and location-based pages (e.g. "best hosting in Germany"). Run the maths: ~400 providers × several page types × 20+ filter combinations. You get to 25,000 pages faster than you'd think.

J'ai choisi Next.js presque sans réfléchir. Nous l'utilisons chez Seahawk pour la plupart de nos gros projets React. L'écosystème est mature, getStaticProps et getStaticPaths ont du sens pour la génération statique lourde en SEO, et personnellement je trouve le routage basé sur les fichiers plus facile à raisonner que Remix ou Gatsby à cette échelle.Next.js almost without thinking about it. We use it at Seahawk for most of our bigger React-based builds. The ecosystem is mature,getStaticProps and getStaticPaths make sense for SEO-heavy static generation, and I personally find the file-based routing easier to reason about than Remix or Gatsby at this scale.

La première vraie décision a été la couche de données. J'ai écartéun CMS headless assez rapidement -- je ne voulais pas payer les tarifs de Contentful pour 25 000 entrées, et je ne faisais pas confiance à un CMS pour gérer proprement les écritures programmatiques en masse. Nous nous sommes arrêtés sur une base de données Postgres sur Supabase, avec une légère couche API Next.js assis devant. Cette partie a en fait fonctionné correctement. C'est à peu près tout le reste qui s'est compliqué.Supabase, with a lightweight Next.js API layer sitting in front of it. That part actually worked fine. It's almost everything else that got complicated.

---

Génération statique à l'échelle : ce que personne ne vous prévient

Voilà le truc avec getStaticPaths et 25 000 routes. Ça marche. Techniquement. Mais vos temps de build vont vous faire remettre en question vos choix de vie.getStaticPaths with 25,000 routes. It works. Technically. But your build times will make you question your life choices.

Notre premier build complet a pris 4 heures et 47 minutes. Sur Vercel. Ce qui, si vous ne faites pas attention à vos limites de forfait, est le genre de chose qui déclenche une notification de facturation à 2h du matin. J'ai fixé cette alerte Slack depuis mon téléphone et j'ai sérieusement envisagé d'utiliser simplement WordPress.

Le piège de `fallback: 'blocking'`

Mon instinct initial était de tout pré-rendre. Chaque page, chaque combinaison. Mauvaise idée -- et pas pour la raison que la plupart des tutoriels vous avertissent (ce qui est généralement juste « ça prend du temps »). Le vrai problème est l'invalidation du cache. Quand un fournisseur d'hébergement met à jour ses prix (et ils le font, constamment), vous devez reconstruire les pages affectées. Si tout est pré-rendu statiquement sans ISR, vous déclenchez des reconstructions complètes pour des changements de données qui affectent peut-être 30 pages sur 25 000.cache invalidation. When a hosting provider updates their pricing (and they do, constantly), you need to rebuild affected pages. If everything is statically pre-rendered with no ISR, you're triggering full rebuilds for data changes that affect maybe 30 pages out of 25,000.

J'ai basculé sur Incremental Static Regeneration avec une revalidate de 86400 secondes (24 heures) pour la plupart des pages, et 3600 secondes pour les pages de fournisseurs lourdes en tarifs. C'a été la meilleure amélioration qualité de vie du projet entier. Les temps de build sont tombés à moins de 40 minutes parce que nous ne pré-rendions que les ~2 000 meilleures pages par priorité de trafic et laissions le reste générer à la demande avec fallback: 'blocking'.Incremental Static Regeneration with a revalidate of 86400 seconds (24 hours) for most pages, and 3600 seconds for pricing-heavy provider pages. This was the single biggest quality-of-life improvement in the entire project. Build times dropped to under 40 minutes because we were only pre-rendering the top ~2,000 pages by traffic priority and letting the rest generate on-demand with fallback: 'blocking'.

Scinder l'arborescence des routes

Une chose que je ferais différemment, et que je dis à tous les devs chez Seahawk qui touchent à un gros projet programmatique maintenant : divisez votre arbre de routes tôt. N'ayez pas une fonction monolithique getStaticPaths essayant de retourner 25 000 slugs. Nous avons divisé la nôtre en :getStaticPaths function trying to return 25,000 slugs. We broke ours into:

  1. /providers/[slug] -- pages de fournisseurs individuels (~400) -- individual provider pages (~400)
  2. /compare/[slugA]-vs-[slugB] -- pages de comparaison face à face (~8 000) -- head-to-head comparison pages (~8,000)
  3. /category/[type] -- pages d'accueil de catégories (~40) -- category landing pages (~40)
  4. /location/[country]/[type] -- combinaisons géo × catégorie (~16 000+) -- geo × category combinations (~16,000+)
  5. /best/[use-case] -- pages de listes curées (~600) -- curated list pages (~600)

Chaque groupe de routes a son propre cadence de revalidation, sa propre logique de récupération de données, et de façon critique, sa propre priorité de build. Les pages de localisation sont presque entièrement à la demande. Les pages de fournisseurs sont toujours pré-rendues. Séparation propre.

---

Le Fouillis du Pipeline de Données (Et Comment Nous L'Avons Réglé)

Au début de 2023, j'ai commis l'erreur de construire le côté collecte de données de HostList trop librement. Nous avions un script de scraping (écrit en Python, utilisant BeautifulSoup et un pool de proxies rotatifs de Webshare), un Google Sheet manuel pour les corrections, et une table Supabase. Trois sources de vérité. Aucune d'elles ne communiquant correctement entre elles.

Un junior dev — bon gars, tout juste sorti d'un bootcamp — a passé trois semaines à maintenir un script de sync entre le Sheet et Supabase qui plantait chaque fois qu'un nom de colonne changeait. J'aurais dû tuer le Sheet à la semaine une et construire une vraie interface admin interne. On l'a finalement fait, avec des routes API Next.js et un dashboard Retool accolé sur le côté, mais on a brûlé probablement 60 heures d'engineering pour y arriver.

La solution : une seule source de vérité, toujours. La base de données est canonique. Tout écrit dans la base de données. L'interface admin lit et écrit dans la base de données. Le scraper écrit dans la base de données. Cela semble évident. Ça l'est toujours, avec le recul.one source of truth, always. The database is canonical. Everything writes to the database. The admin UI reads from and writes to the database. The scraper writes to the database. Sounds obvious. It always does, in hindsight.

Garder les données à jour à grande échelle

Pour un annuaire de cette taille, la fraîcheur des données est une préoccupation SEO autant qu'une question d'UX. Google remarque quand les tableaux de prix affichent £2.99/mois pour un forfait qui coûte £5.99 depuis huit mois. On a mis en place :

  • Un job de scrape hebdomadaire fonctionnant sur une cron Railway (bon marché, fiable, ne nécessite pas de serveur dédié)
  • Un webhook de base de données Supabase qui se déclenche quand une colonne price_updated_at change, en frappant un endpoint de revalidation Next.jsprice_updated_at column changes, hitting a Next.js revalidation endpoint
  • Des drapeaux de remplacement manuel dans Retool pour les ~30 fournisseurs dont les sites bloquent activement les scrapers

Cet endpoint de revalidation -- /api/revalidate?secret=TOKEN&path=/providers/siteground -- c'est une feature stock de Next.js, mais la connecter à un webhook de base de données a demandé un peu de plomberie. Ça en valait chaque minute./api/revalidate?secret=TOKEN&path=/providers/siteground -- is a stock Next.js feature, but wiring it to a database webhook took a bit of plumbing. Worth every minute.

---

Architecture SEO : ce qui a réellement bougé l'aiguille

J'ai créé assez de sites de contenu pour savoir que disposer de 25 000 pages n'est pas la même chose que d'avoir 25 000 pages qui se classent. Les pages de comparaison ont été le piège. Nous avons généré toutes les combinaisons A-vs-B possibles pour nos ~400 fournisseurs, ce qui nous a donné environ 79 800 appairements théoriques. Nous en avons construit ~8 000. Et la plupart d'entre eux étaient, honnêtement, minces.

Confession honnête : j'ai été gourmand. La logique SEO était solide -- « SiteGround vs Bluehost » a un vrai volume de recherche, la long-tail des requêtes de comparaison est énorme -- mais on n'a pas construit assez de contenu unique par page pour justifier l'existence de chacune d'elles. Google a commencé à crawler la section comparaison et a clairement décidé que ce n'était pas digne de son temps. Les propres consignes de Google sur le thin content sont sans détour à ce sujet, et j'aurais dû être plus direct avec moi-même plus tôt.Google's own guidance on thin content is blunt about this, and I should have been blunter with myself earlier.

Ce que nous avons fait pour récupérer

On a fait du nettoyage. Réduit les pages de comparaison de ~8 000 à ~1 200 -- seulement les paires avec un volume de recherche démontrable (vérifié dans Ahrefs, minimum 50 recherches mensuelles à l'échelle mondiale). Puis on a enrichi les pages restantes avec :

  • Des sections dynamiques « c'est mieux pour » extraites des données structurées des fournisseurs
  • Des données réelles de disponibilité (nous avons intégré une API de disponibilité tierce)
  • Des résumés d'avis utilisateurs alimentés par les données de Trustpilot le cas échéant

Le résultat : 1 200 pages qui étaient réellement utiles au lieu de 8 000 pages qui ne l'étaient pas. Le trafic organique vers la section de comparaison a augmenté de 340% au cours des trois mois suivants. Contre-intuitif jusqu'au moment où ça ne l'est plus.

Liaison interne à cette échelle

Avec 25 000 pages, le linking interne ne peut pas être manuel. On a construit un composant related-pages qui interroge Supabase au moment du build (dans getStaticProps) et retourne les cinq pages adjacentes les plus pertinentes en fonction du chevauchement de catégorie et de localisation. Aucune intervention éditoriale nécessaire. Ce n'est pas parfait -- occasionnellement une page VPS hosting se lie à quelque chose qui décale un peu -- mais c'est correct à 90%, et ça a fait en sorte que chaque page avait des liens internes contextuellement pertinents dès le premier jour.getStaticProps) and returns the five most relevant adjacent pages based on category and location overlap. No editorial intervention needed. It's not perfect -- occasionally a VPS hosting page links to something a bit sideways -- but it's 90% right, and it meant every page had contextually relevant internal links from day one.

---

Performance : La partie qui vous humilie

On pourrait croire que la génération statique rend la performance facile. Et au niveau conceptuel, elle le fait -- HTML pré-rendu, edge-cachée sur le CDN de Vercel, pas de surcharge de server-rendering. Mais 25 000 pages signifie 25 000 opportunités d'avoir fait une mauvaise décision à propos de ton arbre de composants.

Notre plus gros problème de performance était le tableau de comparaison des prestataires. C'était un composant React lourd côté client -- beaucoup d'état, beaucoup de rendu conditionnel, utilisé à la fois sur les pages des prestataires et sur les pages de comparaison. Sur mobile, il causait un Largest Contentful Paint d'environ 4,8 secondes. Mauvais. Vraiment mauvais pour un site où le trafic principal est des gens en pleine décision d'achat.Largest Contentful Paint of around 4.8 seconds. Bad. Really bad for a site where the primary traffic is people mid-decision on a purchase.

On l'a reconstruit en tant que tableau statique server-rendered avec une fine couche d'hydratation React pour les bits interactifs du filtre. Le LCP a chuté à 1,9 secondes. Ce n'est pas de la magie -- c'est juste faire correctement la chose ennuyeuse.

Le problème des images

Chaque prestataire a un logo. 400 logos, plus des screenshots, des aperçus UI, des icônes de features. On a fait l'erreur d'héberger ceux-ci sur l'optimisation d'image built-in de Vercel pendant les deux premiers mois. Les coûts de bande passante étaient silencieusement horrifiants. Tout déplacé vers Cloudflare R2 avec un domaine personnalisé, réduit notre facture Vercel de £180/mois à £40/mois. Si tu construis quelque chose de lourd en images, regarde Cloudflare R2 tôt -- l'egress gratuit est genuinely utile à l'échelle.Cloudflare R2 early -- the free egress is genuinely useful at scale.

---

À quoi ressemble vraiment le pipeline de build maintenant

Pour tous ceux qui veulent le tableau concret :

  1. Collecte de données -- scraper Python sur une cron job Railway, écrit dans Supabase Postgres -- Python scraper on a Railway cron job, writes to Supabase Postgres
  2. Couche admin -- tableau de bord Retool pour les modifications manuelles, les corrections et les signalements de fournisseurs -- Retool dashboard for manual edits, corrections, and provider flags
  3. Application Next.js -- Pages router (nous avons commencé avant que App Router soit suffisamment stable pour en faire confiance), déployée sur Vercel -- Pages router (we started before App Router was stable enough to trust), deployed on Vercel
  4. ISR + revalidation à la demande -- environ 2 000 pages pré-générées, le reste à la demande, tous avec revalidation toutes les 24h -- top ~2,000 pages pre-built, rest on-demand, all with 24h revalidation
  5. Images -- Cloudflare R2, servies via un sous-domaine personnalisé avec CDN Cloudflare en façade -- Cloudflare R2, served via a custom subdomain with Cloudflare CDN in front
  6. Analytique -- Plausible pour les données de trafic respectueuses de la vie privée, Ahrefs pour le suivi des classements -- Plausible for privacy-friendly traffic data, Ahrefs for ranking tracking
  7. Surveillance de la disponibilité -- BetterUptime surveille les cinq types de pages les plus lourds en trafic -- BetterUptime watching the five most traffic-heavy page types

Ce n'est pas glamour. C'est aussi largement ennuyeux à maintenir, ce qui est exactement ce que vous voulez d'une infrastructure que vous allez laisser tourner pendant trois ans.

---

Erreurs Honnêtes, Numérotées

  1. J'ai commencé trop large. 25 000 pages était toujours l'objectif, mais j'aurais dû lancer avec 500 pages de haute qualité et m'étendre progressivement. À la place, j'ai lancé avec tout et j'ai eu un problème de budget de crawl Google pendant les quatre premiers mois.25,000 pages was always the goal, but I should have launched with 500 high-quality pages and expanded. Instead I launched with everything and had a Google crawl budget problem for the first four months.
  2. Je n'ai pas mis en place la revalidation correctement dès le premier jour. Nous avons perdu deux mois sur des reconstructions complètes qu'ISR aurait rendues inutiles.We wasted two months on full rebuilds that ISR would have made unnecessary.
  3. J'ai gardé le Google Sheet. Une source de vérité unique aurait dû être non-négociable dès la première semaine.Single source of truth should have been non-negotiable from week one.
  4. J'ai sous-estimé la qualité des pages de comparaison. Le volume n'est pas une stratégie.Volume is not a strategy.
  5. Utilisation trop longue de l'optimisation d'images Vercel. Migration vers R2 six semaines plus tard que prévu.Moved to R2 six weeks later than we should have.
  6. On n'a pas divisé l'arborescence des routes assez tôt. Routes rapides et lentes mélangées dans le même appel getStaticPaths et on se demandait ensuite pourquoi les builds étaient lents.Mixed fast and slow routes in the same getStaticPaths call and then wondered why builds were slow.

Chacune de ces décisions semblait raisonnable au moment où on l'a prise. C'est la partie que les tutoriels ne capturent pas -- les mauvaises décisions architecturales ont généralement de bonnes justifications quand vous les prenez.

---

FAQ

Combien de temps a pris le build initial pour se mettre en ligne ?

Sept mois entre le premier commit et une version que j'étais à l'aise d'appeler v1. La première version publique brute était en ligne après environ quatre mois, mais elle avait des problèmes sérieux de contenu insuffisant et la section de comparaison était surtout inutile. Je dirais quatre mois pour « techniquement en ligne » et trois autres pour « réellement bon ».

Utiliseriez-vous l'App Router si vous commenciez aujourd'hui ?

Probablement oui, pour les nouveaux projets lancés fin 2023 et après. Les composants serveur de l'App Router seraient bien adaptés à ce genre de génération de pages gourmandes en données. Mais migrer une appli Pages Router existante de 25 000 pages n'est pas un projet que je vais entreprendre de sitôt. Le Pages Router fonctionne toujours, et « fonctionner » c'est sous-estimé.

Comment gérez-vous les fournisseurs qui cessent leurs activités ou changent considérablement leur offre ?

Nous avons un indicateur de statut dans la base de données -- active, deprecated, redirected. Les fournisseurs deprecated reçoivent une page d'archive allégée plutôt qu'une suppression complète, ce qui préserve les backlinks. Les fournisseurs redirected (par exemple quand un hébergeur en acquiert un autre) reçoivent une redirection 301 gérée via la config next.config.js de Next.js. Nous vérifions les indicateurs de statut mensuellement.status flag in the database -- active,deprecated,redirected. Deprecated providers get a slim archive page rather than a full removal, which preserves any backlinks. Redirected providers (e.g. when one host acquires another) get a 301 handled via the Next.js redirects config in next.config.js. We review the status flags monthly.

Qu'utiliseriez-vous à la place de Next.js si vous refaisiez cela ?

Je ne sais vraiment pas. Astro est intéressant pour les sites de contenu surtout statique, et j'ai expérimenté avec sur un projet plus petit. Mais Next.js nous a donné la flexibilité d'avoir à la fois des sections statiques et dynamiques dans la même base de code, ce qui importait. Pour un répertoire purement statique sans fonctionnalités interactives, Astro pourrait être plus rapide à construire et moins cher à exploiter. Posez-moi la question à nouveau dans un an.Astro is interesting for mostly-static content sites, and I've been playing with it on a smaller project. But Next.js gave us the flexibility to have both static and dynamic sections in the same codebase, which mattered. For a purely static directory with no interactive features, Astro might be faster to build and cheaper to run. Ask me again in a year.

Comment empêchez-vous les scrapers de copier tout le répertoire ?

Honnêtement ? Vous ne pouvez pas, complètement. Nous limitons la fréquence des requêtes sur les routes API, utilisons la gestion des bots de Cloudflare sur le frontend, et faisons tourner une partie des données structurées pour que les copies scrapées deviennent rapidement obsolètes. Mais si quelqu'un veut cloner un répertoire accessible publiquement, il trouvera un moyen. Le moat c'est la fraîcheur des données et la qualité de l'UX, pas l'obfuscation technique.

---

Pensée Finale

HostList n'est pas un succès fulgurant. Cela génère des revenus -- commissions d'affiliation, quelques contrats publicitaires directs -- et se classe raisonnablement bien pour peut-être 600 des termes que je visais initialement. C'est correct. C'était un projet d'apprentissage qui génère aussi du revenu, ce qui est le mieux qu'on puisse espérer.

Si vous envisagez de construire un grand site SEO programmatique sur Next.js, mon conseil honnête est celui-ci : faites-le. C'est véritablement une bonne stack pour le travail. Mais construisez moins que ce que vous pensez avoir besoin, construisez-le mieux que ce que vous pensez avoir le temps de faire, et réglez votre architecture de données avant d'écrire un seul modèle de page.

La technologie, c'est la partie facile. Ça l'est toujours.

< BACK