Aller au contenu

Note d'ingénierie

Site vitrine Next.js : une langue, zéro locale fantôme, 169 ko de JS

Par Alexandre Kaczor7 min de lecture
  • Next.js
  • SEO
  • Performance
  • Architecture
Illustration de l'article « Site vitrine Next.js : une langue, zéro locale fantôme, 169 ko de JS »

Locale fantôme supprimée, redirections 308 sans chaîne, lastmod sincère, métadonnées éditables en back-office et un budget JS à 169 ko quand React et Next en prennent déjà 145 : la refonte de ce site.

Le site que vous lisez est parti d'un thème du commerce, Techwind, branché sur un CMS maison : une API NestJS, un back-office et un front Next.js. Il cumulait les défauts qui coûtent en référencement : des URL de démonstration (/page-aboutus, /portfolio-detail-one/…), un préfixe de langue sur chaque page et une bibliothèque d'internationalisation embarquée dans tous les bundles. J'ai refondu le front avec une règle : chaque choix technique doit dire la vérité aux moteurs.

La locale fantôme : pourquoi le site est passé en français seul

Le thème servait toutes les pages sous /fr/… et /en/…. Le problème venait du modèle de données : les entités Project et Article n'ont aucun champ de langue. La version /en affichait donc exactement le même texte français, sous une locale déclarée anglaise.

C'est ce que j'appelle une locale fantôme : deux URL pour un même contenu, dont l'une ment sur sa langue. Pour un moteur : contenu dupliqué et signaux de langue contradictoires.

J'ai choisi l'autre option : français seul, segment [locale] supprimé et URL à la racine. Le layout déclare <html lang="fr">, le JSON-LD du site porte inLanguage: "fr-FR" et Open Graph locale: "fr_FR". Les trois signaux disent désormais la même chose.

La décision a eu un effet de bord intéressant : react-intl devenait inutile. Un commentaire du code garde la trace du chiffre :

/**
 * Remplace `react-intl`, devenu inutile depuis que le site est en français seul :
 * la bibliothèque pesait près de 390 ko pour formater une unique locale. Les
 * libellés atteignables statiquement ont été écrits directement dans les
 * composants ; cette fonction ne sert plus qu'aux tableaux de données qui
 * référencent leur clé dynamiquement.
 */
export function t(id: string): string {
  return MESSAGES[id] ?? id;
}

Des redirections 308 sans chaîne

Supprimer des URL ne suffit pas. Il faut que chaque ancienne adresse, qu'elle ait été indexée, partagée ou mise en favori, mène en un seul saut à sa destination finale. Une chaîne A → B → C ralentit l'exploration. Tout tient dans une table de next.config.ts :

const LEGACY_PATHS: Record<string, string> = {
  "/portfolio-modern-three": "/portfolio",
  "/portfolio-detail-one/quarrymaster-ios-lidar": "/portfolio/lidark",
  "/portfolio-detail-one/:slug": "/portfolio/:slug",
  // QuarryMaster renommé Lidark.
  "/portfolio/quarrymaster-ios-lidar": "/portfolio/lidark",
  "/page-aboutus": "/a-propos",
  // ...
};

for (const [from, to] of Object.entries(LEGACY_PATHS)) {
  // URL sans préfixe de locale, puis les deux variantes préfixées historiques.
  redirects.push({ source: from, destination: to, permanent: true });
  for (const locale of ["fr", "en"]) {
    redirects.push({ source: `/${locale}${from}`, destination: to, permanent: true });
  }
}

Trois détails comptent :

  • Chaque ancienne URL existe en trois variantes (sans préfixe, /fr et /en), et les trois pointent directement vers la cible finale. /en/page-aboutus ne passe pas par /page-aboutus : il va droit à /a-propos.
  • L'ordre fait partie de la logique. Next applique la première règle qui correspond. L'entrée précise de QuarryMaster est placée avant le motif générique :slug, sinon l'ancienne fiche serait d'abord renvoyée vers /portfolio/quarrymaster-ios-lidar, puis une seconde fois vers Lidark. On aurait recréé la chaîne qu'on voulait éviter.
  • permanent: true produit un 308, et non un 307. C'est l'équivalent moderne du 301 : la méthode HTTP est conservée et le transfert est définitif.

Une seconde boucle retire le préfixe des pages conservées, et /fr comme /en mènent à l'accueil.

Un lastmod sincère, ou pas de lastmod du tout

Le sitemap est l'endroit où l'on ment le plus facilement sans s'en rendre compte. La tentation est de dater chaque URL avec la date de modification du fichier. Or cette date est réécrite par chaque git checkout et par chaque build Docker. Toutes les URL portent alors l'heure du dernier déploiement. Google cesse de faire confiance au signal pour l'ensemble du site, et pas seulement pour les pages concernées.

J'ai donc tranché en deux camps :

// Pages éditoriales fixes. Volontairement sans `lastmod`.
const staticEntries = STATIC_PATHS.map((path) => ({
  url: `${SITE_URL}${path === "/" ? "" : path}`,
}));

// Les pages issues du CMS portent un `lastmod` réel.
const articleEntries = articles
  .filter((article) => article.slug)
  .map((article) => ({
    url: `${SITE_URL}/blog/${article.slug}`,
    lastModified: new Date(article.updatedAt),
  }));

Les fiches du portfolio et les articles ont une vraie date : updatedAt est un champ de base de données mis à jour à chaque édition. Les pages fixes n'en ont pas de fiable, donc elles n'en déclarent pas. Le flux RSS applique la même règle : lastBuildDate reprend la plus récente des dates updatedAt des articles, pas l'heure du build. Sinon, chaque redéploiement annoncerait à tort une nouveauté aux agrégateurs.

Un piège est apparu en production. Le sitemap est généré au build, quand l'API n'est pas joignable. Il sortait donc vide de fiches et d'articles jusqu'à l'expiration de son cache. La route de revalidation purge désormais aussi /sitemap.xml.

Les métadonnées se règlent dans le back-office

Retoucher un titre ou une description ne devrait pas exiger un déploiement. Une table SeoMetadata, indexée par route, est éditable dans le back-office. Chaque page appelle un constructeur unique qui retombe sur ses propres valeurs si l'entrée n'existe pas :

export async function buildMetadata(path: string, fallback: MetadataFallback) {
  const seo = await getSeoByRoute(path);
  const title = seo?.title || fallback.title;
  /*
    Image de marque par défaut, déclarée EXPLICITEMENT : dès qu'une page pose son
    propre objet `openGraph`, Next n'y ajoute plus `app/opengraph-image.tsx` —
    constaté en prod, aucune page n'avait d'og:image.
  */
  const image = seo?.ogImage || fallback.ogImage || "/opengraph-image";
  return {
    title,
    alternates: { canonical: url, types: BLOG_FEED_ALTERNATE },
    // ...
  };
}

Le commentaire raconte un vrai bug : l'image de partage avait disparu de toutes les pages sans qu'aucun build ne le signale. Le flux RSS a subi le même sort : Next remplace l'objet alternates du layout au lieu de le fusionner, d'où la constante BLOG_FEED_ALTERNATE répétée dans chaque page.

Deux fonctions bornent les titres à 60 caractères et les descriptions à 160, et une route /llms.txt générée depuis le CMS résume le site pour les assistants IA. Après une édition, l'API appelle POST /api/revalidate avec un secret partagé, et la modification apparaît sans attendre l'heure de revalidation ISR.

169 ko de JavaScript, et 24 ko de marge réelle

Le référentiel que je me suis fixé limite le JavaScript transféré à 170 ko par route publique. Après la refonte, on mesure environ 169 ko. Cela paraît confortable jusqu'à ce qu'on regarde le plancher : React et Next pèsent à eux seuls environ 145 ko. Mon propre code et chaque bibliothèque ajoutée doivent tenir dans la vingtaine de kilo-octets restante.

Cette contrainte change la façon de développer. Le calculateur de coût LLM de la page Outils n'importe aucune dépendance :

/**
 * Aucune dépendance cliente : le budget JavaScript du site est au plafond, une
 * bibliothèque de graphiques ou de formulaire le ferait dépasser. Les barres
 * sont des `div` en pourcentage, les calculs de l'arithmétique.
 */

Ce qui manque : ce budget est mesuré, pas imposé. La CI vérifie le lint, les types, le build et les tests de sécurité, mais aucun contrôle ne fait échouer un push qui dépasserait les 170 ko. Pour l'instant, la garantie tient à un commentaire et à une habitude.

Ce que j'en retiens

  • Une langue qu'on ne traduit pas ne doit pas apparaître dans l'URL. Le modèle de données dit si le site est multilingue, pas le thème.
  • Une redirection se vérifie de bout en bout. Chaque ancienne URL doit atteindre sa cible finale en un seul 308, et l'ordre des règles fait partie du code.
  • Une date absente vaut mieux qu'une date fausse. Pas de lastmod sans source sincère, ni dans le sitemap ni dans le flux RSS.
  • Un budget JS se lit à partir du plancher. 169 ko sur 170 ne laisse en réalité qu'environ 24 ko pour son propre code.

Votre site vitrine traîne des URL de thème, des langues fantômes ou un bundle trop lourd ? Écrivez-moi : regardons ensemble ce qu'il faut corriger en premier.