Aller au contenu principal

Journal de bord · Phase 0 · 7/7

Le SEO multilingue d'aubia.dev : URL, hreflang et JSON-LD

Les six langues d'aubia.dev ont leurs URL et leurs liens hreflang. Le HTML servi relie aussi les articles à leur auteur, sans garantir leur indexation ni leur citation par une IA.

Publié le 24 août 2026Mis à jour le 15 septembre 202611 min de lecture
  • seo
  • i18n
  • geo
  • inertia

Une visite sur /fr/blog/multilingual-seo-geo doit recevoir cet article en français, même si le navigateur préfère l'anglais. Le cookie de langue ne doit pas davantage changer le contenu de cette URL. Pour partager une traduction ou la faire indexer, j'ai besoin que son adresse désigne toujours la même langue.

Le site aubia.dev sert ses pages publiques sous six préfixes : /fr, /en, /es, /de, /it et /pt. Les slugs restent en anglais dans toutes les versions. Le préfixe suffit à les distinguer.

Le rendu serveur d'Inertia fournit le contenu et les métadonnées dans la réponse HTML initiale. Un robot peut les lire sans exécuter le JavaScript de la page. Cela suppose que le SSR fonctionne : en cas d'échec, la configuration du site autorise un repli vers le rendu client. Le contenu dépend alors du JavaScript, comme le détaille l'article consacré à cette stack.

La langue de l'URL et celle du navigateur

Le middleware SetLocale choisit la langue avant le rendu. Il consulte le segment d'URL, puis le cookie de préférence, puis l'en-tête Accept-Language du navigateur, et enfin la configuration de l'application. La première valeur reconnue est retenue.

Sur une page sous /fr, le segment d'URL gagne donc toujours. Sur /, il n'y en a pas : le cookie, les préférences du navigateur et la langue par défaut, l'anglais, déterminent la destination de la redirection.

Pour lire Accept-Language, le serveur parcourt les langues dans leur ordre de préférence et compare leurs deux premières lettres aux six langues acceptées. Sans cookie prioritaire, un navigateur qui demande pt-BR peut ainsi être dirigé vers /pt.

La racine répond avec une redirection 302. Une 301 indiquerait un déplacement permanent alors que la destination dépend du visiteur et de sa préférence du moment.

Une réponse qui varie selon la requête

Un cache partagé ne doit pas réutiliser une redirection vers le français pour un visiteur qui demande l'italien. L'en-tête Vary indique quels en-têtes de requête distinguent les réponses.

Inertia définit Vary: X-Inertia pour séparer ses réponses HTML et JSON. Son middleware remplace cet en-tête ; SetLocale ajoute donc les valeurs liées à la langue après son retour, uniquement sur la route de redirection :

if ($request->routeIs('home.redirect')) {
    $response->headers->set('Vary', ['Cookie', 'Accept-Language'], replace: false);
}

La réponse comprend alors X-Inertia, Cookie et Accept-Language. Les pages localisées conservent le Vary d'Inertia, sans ajout lié à la négociation de langue : leur URL détermine déjà celle-ci.

Ce mécanisme dépend du cache qui reçoit la réponse. Cloudflare ne tient pas compte par défaut de toutes les valeurs de Vary. Émettre l'en-tête ne suffit donc pas à prouver que le CDN distingue ces variantes ; ses règles de cache doivent être compatibles avec les réponses servies.

Hreflang relie les traductions

Une URL par langue rend chaque version accessible séparément. L'annotation hreflang indique aux moteurs quelles pages sont les traductions les unes des autres.

Chaque version annonce sa propre URL et celles des autres langues disponibles. Google demande cette réciprocité : si deux pages ne se référencent pas mutuellement, les annotations de cette paire peuvent être ignorées. Les paires qui se répondent peuvent toujours être traitées.

J'utilise des codes de langue seuls : fr, en, es, de, it et pt. Le site propose une version portugaise destinée aux lusophones, pas des contenus distincts pour le Portugal et le Brésil. pt décrit ce choix ; pt-PT annoncerait un contenu en portugais destiné au Portugal.

L'annotation renseigne le moteur sur le public visé. La redirection HTTP de la racine, elle, choisit la destination d'une visite selon la requête reçue.

Une version de repli

L'annotation x-default désigne une version de repli pour les langues ou régions non couvertes. Sur aubia.dev, elle pointe vers l'anglais. Pour un article dont la traduction anglaise n'est pas publiée, elle est omise : une URL en 404 ne ferait pas une destination de repli utile.

Les annotations du blog sont limitées aux traductions publiées du même slug. Un article disponible dans trois langues annonce ces trois versions, même si le reste du site en accepte six.

Le composant SeoHead écrit ces liens dans le <head>. Le sitemap les produit aussi à partir des langues publiées. Les deux méthodes sont équivalentes pour Google ; les cumuler n'ajoute pas de bénéfice de référencement. Leur maintien impose surtout de conserver les mêmes destinations des deux côtés.

Un canonical propre à chaque langue

Le lien canonical répond à une autre question : quelle URL préférer parmi des pages identiques ou très proches ? Une traduction complète n'est pas un doublon de l'original au seul motif qu'elle traite du même sujet dans une autre langue.

Chaque version d'un article déclare donc sa propre URL canonique. La page française ne désigne pas la page anglaise comme canonical. Google recommande une cible dans la même langue lorsqu'elle existe, et conserve la décision finale sur l'URL retenue.

Les pages de confirmation et de désinscription sont en noindex,nofollow. Le site n'y ajoute ni canonical ni hreflang, et les exclut du sitemap. Ce choix réserve les annonces de versions linguistiques aux pages destinées à l'indexation.

Open Graph utilise un autre format : og:locale vaut par exemple fr_FR. La spécification Open Graph prévoit language_TERRITORY pour cette propriété facultative. Le format avec territoire ne se transpose pas au ciblage hreflang.

Des liens de langue présents avant le JavaScript

Le sélecteur de langue contient de vrais liens vers les autres versions de la page. Google recommande ces liens en complément des annotations hreflang, pour que le visiteur puisse choisir lui-même.

Le composant utilise les éléments HTML natifs details et summary. Les ancres sont rendues même lorsque le panneau est fermé. Avec le SSR, elles figurent donc dans le HTML reçu, sans attendre un clic pour être ajoutées au document.

L'ouverture fonctionne à la souris et au clavier grâce au navigateur. Un effet React ajoute la fermeture à Échap et au clic extérieur. Un menu qui ne monte son contenu qu'à l'ouverture ne fournirait pas ces liens dans le rendu initial ; cette limite dépend du comportement du composant, pas de la seule présence d'un portail React.

Les destinations viennent du serveur. Sur un article, elles sont restreintes aux traductions publiées, comme les hreflang. Le sélecteur ne propose pas une langue dont l'article est absent.

Un auteur identifié dans le graphe JSON-LD

Le composant SeoHead décrit trois entités communes en JSON-LD : l'organisation Aubia, ma personne et le site. JSON-LD utilise ici le vocabulaire de schema.org pour nommer les types et les relations.

Chaque entité possède un @id stable. Les références à ces identifiants permettent de déclarer une relation sans répéter l'objet entier. Le graphe contient notamment les liens suivants, présentés dans cet extrait réduit aux types, identifiants et relations :

{
    "@context": "https://schema.org",
    "@graph": [
        {
            "@type": "Organization",
            "@id": "https://aubia.dev/#organization",
            "founder": { "@id": "https://aubia.dev/#founder" }
        },
        {
            "@type": "Person",
            "@id": "https://aubia.dev/#founder",
            "worksFor": { "@id": "https://aubia.dev/#organization" }
        },
        {
            "@type": "WebSite",
            "@id": "https://aubia.dev/#website",
            "publisher": { "@id": "https://aubia.dev/#organization" }
        }
    ]
}

Les pages ajoutent leurs données par additionalJsonLd. L'accueil fournit un FAQPage avec les questions et réponses visibles. Un article ajoute un BlogPosting et un BreadcrumbList, son fil d'Ariane.

Dans le BlogPosting, author référence #founder et publisher référence #organization. L'auteur de l'article est ainsi déclaré comme la même personne que le fondateur du site. Ajouter un objet dans @graph ne crée pas à lui seul une relation avec tous les autres objets : ce sont les propriétés et leurs références qui la décrivent.

La signature visible de chaque article renvoie au même profil public que le nœud Person. Les métadonnées Open Graph déclarent le type article et ses dates de publication et de modification.

Le graphe ne déclare ni SoftwareApplication ni offre de précommande : le site propose une liste d'attente, pas un logiciel disponible au téléchargement. Aucun avis ni aucune note ne sont inventés pour obtenir un résultat enrichi. La FAQ visible conserve son balisage, identifié par l'URL de la page localisée, mais Google ne propose plus de résultats enrichis FAQ depuis le 7 mai 2026. Ces déclarations ne garantissent ni leur utilisation par un moteur ni l'indexation.

Le GEO, sans garantie de citation

Le terme GEO, pour Generative Engine Optimization, désigne les pratiques destinées à améliorer la visibilité d'un contenu dans les réponses produites par des IA. Il ne correspond pas à un protocole commun qui assurerait qu'une page sera lue, retenue ou citée.

Pour apparaître comme lien source dans AI Overviews ou AI Mode de Google Search, une page doit être indexée par Google et pouvoir être affichée avec un extrait. Aucun balisage schema.org spécial n'est requis. Même une page qui remplit ces conditions n'a aucune garantie d'apparaître comme source.

Je cherche à écrire des sections compréhensibles sans devoir relire tout l'article. Cela aide à suivre une explication et à en citer un passage, sans garantir qu'une IA le restituera fidèlement.

Un fichier llms.txt à maintenir

Le site sert aussi un fichier llms.txt. Il contient une présentation du produit en Markdown et des liens vers des pages publiques, sans interface à parcourir. Il s'inspire de la proposition llms.txt, distincte des règles de crawl de robots.txt.

Ce fichier est écrit séparément du site et des articles. Ses affirmations et ses liens doivent donc être vérifiés lorsqu'ils changent ailleurs. Sa présence ne prouve ni qu'un moteur le consulte ni qu'il améliore le classement ou les citations du site.

La publication des traductions

Un article du blog est un fichier Markdown par langue. Sa date de publication est lue dans le front matter, en UTC. Le build compile le Markdown et sa coloration Phiki dans un catalogue local obligatoire en production, articles futurs compris. Leur date est réévaluée à chaque lecture : avant la date, l'URL répond 404 ; une fois celle-ci atteinte, l'article devient accessible sans nouveau déploiement. Un catalogue absent, invalide ou périmé provoque une réponse 503 sur les routes qui en dépendent, sans relancer Phiki pendant la requête.

L'index le référence et le RSS publie son chapô avec sa date de publication. Le sitemap utilise sa date de mise à jour, ou sa date de publication à défaut, pour lastmod. Il ajoute les hreflang des traductions disponibles. La page elle-même fournit le BlogPosting et le fil d'Ariane.

Le RSS et le sitemap sont servis sans session ni cookies et annoncent une durée de cache HTTP d'une heure. Leur actualisation dans un cache intermédiaire ou chez un lecteur peut donc être décalée par rapport à l'ouverture de l'article côté application. Le lastmod de chaque index du blog correspond à la date la plus récente de ses articles publiés, modification ou publication. Les pages statiques n'annoncent pas de date inconnue. Le <head> des pages du blog contient aussi un lien de découverte du flux RSS localisé.

Le texte d'interface suit une autre chaîne

Les textes de l'interface sont stockés dans les fichiers lang/<locale>.json. Le français sert de source, l'anglais de pivot vers l'espagnol, l'allemand, l'italien et le portugais.

Au commit d'une modification du français, le hook transmet à l'agent de traduction les clés ajoutées ou modifiées qui ne sont pas exclues. Il fusionne les valeurs reçues dans le fichier anglais et conserve les autres. Les suppressions de clés sont appliquées séparément. La propagation de l'anglais vers les quatre autres langues se lance sur commande manuelle.

Trois préfixes sont exclus des ajouts et modifications automatiques : usp.*, features.tagline.* et letter.body.*. Les deux premiers n'ont pas de clés dans les dictionnaires actuels. Le troisième protège le corps de la Lettre du concepteur. Le claim et le titre de la bannière d'accueil ne font pas partie de ces exclusions ; cela les rend admissibles à la traduction automatique.

Le serveur charge les traductions de la langue demandée et complète les clés manquantes avec l'anglais. Le hook React useT() lit ensuite les valeurs partagées dans les props Inertia. Les articles ne passent pas par ce script : leurs fichiers Markdown sont traduits séparément.

Le corps de la Lettre n'existe que dans les dictionnaires français et anglais. En espagnol, allemand, italien et portugais, ses paragraphes s'affichent donc en anglais, même si les titres et libellés autour sont traduits. Le conteneur de ces paragraphes indique lang="en" : cette annotation identifie leur langue, sans les traduire.

Ce chantier se raconte ici, article après article. Ce qui vient ensuite dépend de ce que vous en direz.

Rejoindre la liste d'attente