<link rel="stylesheet" href="/assets/fonts/inter/inter.css" />
All posts

Comment j'ai diagnostiqué et résolu une crise d'indexation SEO en production : étude de cas

Statut : brouillon personnel, en attente de relecture avant publication.

Ceci n'est pas un guide théorique : c'est le récit d'une vraie session de débogage sur ce site même — un portfolio personnel construit avec Angular 21 (standalone components, SSR/prerendering, signals), un backend NestJS + MongoDB sur Railway et un déploiement statique via FileZilla sur un hébergement Plesk. Le symptôme initial était simple à décrire et frustrant à diagnostiquer : zéro page indexée sur Google, alors que le site était en ligne depuis des mois avec du vrai contenu. Ce qui ressemblait à un problème a fini par en révéler cinq, imbriqués les uns dans les autres.

Le stack et le contexte

  • Frontend : Angular 21, standalone components, déployé comme site statique pré-rendu (aucun serveur Node actif en production — FileZilla envoie le résultat du build sur Plesk).
  • Backend : NestJS + MongoDB, déployé sur Railway, derrière un rate limiter (@nestjs/throttler).
  • Contenu : un blog d'articles techniques, traduit en 7 langues (italien par défaut + anglais, albanais, espagnol, portugais, français, allemand).

Symptôme 1 : le site n'indexe rien

Premier diagnostic, le plus simple : comparer le HTML que reçoit réellement un crawler pour un ancien article (qui fonctionne) et pour un nouveau (tout juste publié).

$ curl -s https://gentsallaku.it/blog/articolo-vecchio | grep "<title>"
<title>Operatori RxJS: Guida Pratica con Casi d'Uso Reali | Gent Sallaku</title>

$ curl -s https://gentsallaku.it/blog/articolo-nuovo | grep "<title>"
<title>Gent Sallaku | Senior Front-End & API Developer</title>

La deuxième commande en est la preuve : le crawler reçoit le titre générique de la page d'accueil, pas celui de l'article. La cause : le site est statique, chaque page est générée (pré-rendue) au moment du build, et chaque nouvel article avait été inséré directement en base sans jamais relancer le build ni l'envoi. Google voyait littéralement le dernier build disponible — qui ne contenait pas les contenus les plus récents. Pas un bug de code, un trou dans le processus.

Symptôme 2 : même les anciens articles n'étaient pas tous suivis

Deuxième découverte : le sitemap.xml exposé par le site ne listait qu'une poignée d'URL statiques, sans une seule entrée pour les articles du blog. Même les posts correctement pré-rendus n'avaient aucun moyen d'être découverts systématiquement par Google, sinon par l'exploration organique des liens internes — bien plus lente qu'un sitemap déclaré explicitement.

Symptôme 3 : la vraie base de production s'appelait « test »

C'est là que le diagnostic a cessé d'être banal. En cherchant pourquoi certains contenus publiés n'apparaissaient jamais, j'ai vérifié directement la chaîne de connexion MongoDB utilisée en production sur Railway :

MONGODB_URI=mongodb://mongo:•••@mongodb-rhkn.railway.internal:27017

Aucun nom de base dans la chaîne. Quand le driver Mongo ne trouve pas de path explicite, il se rabat silencieusement sur une base nommée « test ». La base « correcte », nommée portfolio_prod, existait bien sur le même serveur — mais elle était abandonnée, figée sur onze posts vieux de plusieurs mois. Le site en ligne servait en réalité du contenu depuis une base que n'importe qui aurait raisonnablement pu croire disponible pour des tests destructifs.

La correction a demandé trois étapes, dans l'ordre, pour ne pas perdre de vraies données en route :

  1. mongodump de la base test (28 posts, des milliers de pages vues, des données de consentement RGPD — que du contenu réel)
  2. mongorestore --drop dans portfolio_prod, sur le même serveur
  3. Mise à jour de la variable MONGODB_URI sur Railway pour pointer explicitement vers portfolio_prod, avec le redéploiement automatique qui en découle

Vérifié par un appel direct à l'API juste après la bascule, pour confirmer l'absence totale de perte de données avant de clore le chapitre.

Symptôme 4 : les pages pré-rendues étaient toujours en anglais

En contrôlant le contenu réel des pages statiques générées au build, un détail clochait : le texte de la page d'accueil — bio, sections, libellés — apparaissait toujours en anglais, même pour la seule langue censée être la langue par défaut : l'italien.

export function resolveInitialLanguage(): Lang {
  if (typeof localStorage !== 'undefined') { /* ... */ }
  if (typeof navigator !== 'undefined') { /* ... */ }
  return 'en'; // ← eseguito SEMPRE durante il prerendering: Node non ha
               //    né localStorage né navigator
}

Pendant le prerendering, l'environnement est Node — il n'y a pas de navigateur, donc ni localStorage ni navigator ne sont disponibles, et la fonction se rabattait toujours sur la valeur codée en dur 'en'. Chaque page statique du site avait toujours été générée en anglais, quelle que soit la langue déclarée.

Symptôme 5 : des hreflang qui mentaient à Google

La dernière découverte a été la plus sournoise, car tout semblait correct : chaque page déclarait correctement 7 balises hreflang, une par langue, selon les bonnes pratiques. Le problème venait du format des URL déclarées :

<link rel="alternate" hreflang="en" href="https://gentsallaku.it/blog/articolo?lang=en" />
<link rel="alternate" hreflang="es" href="https://gentsallaku.it/blog/articolo?lang=es" />

Aucune page de l'application ne lisait jamais ce paramètre ?lang=. Chaque variante déclarée renvoyait en fait au même HTML, à l'identique (la version anglaise vue plus haut). Google recevait 7 déclarations de contenu en langues différentes qui menaient toutes à la même page — un signal ignoré dans le meilleur des cas et qui, dans le pire, réduisait la confiance globale dans le site.

La solution structurelle : des URL réellement préfixées par langue

La bonne correction n'était pas un paramètre à lire, mais un changement d'architecture : des URL réellement distinctes par langue (/en/blog/articolo, /es/blog/articolo...), pour que le prerenderer génère un contenu réellement différent pour chacune, et non le même fichier répété sept fois avec une étiquette différente.

Première tentative, et pourquoi elle n'a pas marché

Le premier design utilisait un UrlMatcher personnalisé pour intercepter le préfixe de langue sans dupliquer tout l'arbre des routes. Correct sur le papier — mais le premier vrai build a cassé toutes les pages existantes, pas seulement les nouvelles :

✘ ERROR: The 'homepage' server route does not match any routes
  defined in the Angular routing configuration.

Le prerenderer d'Angular refuse explicitement de pré-rendre toute route dotée d'un matcher personnalisé et, plus en amont, ne descend même pas dans ses children lors de la validation croisée client/serveur. Une limitation peu documentée, découverte uniquement en tentant réellement le build — exactement le genre de risque qu'aucune analyse statique du code n'aurait pu prévoir avec certitude.

La solution : remplacer le matcher par une route path: ':lang' protégée par un guard canMatch — même comportement (il ignore les segments qui ne sont pas des codes de langue valides, par ex. /dashboard), mais exprimé comme un vrai segment de path, que le prerenderer sait effectivement parcourir.

L'effet de bord caché : le rate limiter

Une fois la nouvelle structure en place, un second problème est apparu seulement après avoir étendu le prerendering à toutes les langues : environ un quart des posts, de manière apparemment aléatoire, étaient générés comme « article introuvable » au lieu du vrai contenu.

La cause : générer 29 posts × 7 langues revient à lancer plus de 200 appels à l'API du blog en moins d'une minute, tous depuis la même IP de la machine de build — ce qui dépassait le rate limit par défaut du backend (60 requêtes/60 secondes). Une première tentative de correction par des retries côté client a aggravé la situation (pages bloquées en plein chargement, car le prerenderer d'Angular a un temps d'attente maximal par route et les retries le dépassaient). La bonne correction se situait en amont : relever le rate limit uniquement sur les deux endpoints publics et en lecture seule du blog, en laissant intacte la protection du login et des formulaires :

@Get('posts/:slug')
@Throttle({ default: { limit: 300, ttl: 60000 } }) // da 60 a 300 su questo endpoint
@ApiOperation({ summary: 'Get published post by slug (public)' })
findBySlug(@Param('slug') slug: string) {
  return this.blogService.findBySlug(slug);
}

Résultats mesurables

MétriqueAvantAprès
Pages statiques générées au build45273+
Entrées dans sitemap.xml~40 (aucun post individuel)242
Variantes hreflang fonctionnelles0 sur 7 (même HTML pour toutes)7 sur 7, contenu réellement distinct
Base de données de production« test » (implicite, non déclarée)portfolio_prod (explicite)
Taux de posts « introuvable » dans le build multilingue~25%0%

Leçons apprises

  • Vérifiez toujours avec curl, pas à l'œil : le titre générique de la page d'accueil à la place de celui de l'article n'a été repéré qu'en comparant les octets réellement reçus par un crawler, et non en regardant le site dans un navigateur classique (qui masque ces problèmes en exécutant quand même le JavaScript).
  • Un nom de base implicite est un vrai risque, pas une simple négligence esthétique : n'importe qui ayant lancé un script « juste pour tester » contre une base littéralement nommée test aurait pu modifier des données de production sans le savoir.
  • Des données structurées « correctes sur le papier » doivent être vérifiées de bout en bout : les hreflang étaient syntaxiquement parfaits mais sémantiquement faux, car personne n'avait vérifié que les variantes déclarées menaient vraiment à des contenus différents.
  • Les limitations non documentées n'apparaissent qu'en tentant le vrai build : aucune lecture du code ou de la documentation n'aurait révélé qu'Angular refuse le prerendering des routes avec matcher — seule l'erreur de build l'a rendu évident.
  • Une correction « défensive » peut aggraver un symptôme au lieu de le résoudre : le retry côté client semblait la réponse évidente au rate limiting et a au contraire introduit un échec silencieux pire (pages bloquées) que celui qu'il devait corriger.

💬 Notes des lecteurs

0 notes

Écrire une note

Partagez votre avis, une suggestion ou un compliment

Dernières notes

Aucune note pour le moment. Soyez le premier à commenter !