Aller au contenu principal

Journal de bord · Phase 0 · 7/7

SEO multilingue et GEO : 6 locales, SSR et extraction IA

Six URL indexables par langue, un hreflang BCP47 bidirectionnel, un graphe JSON-LD relié par @id et un llms.txt : comment aubia.dev se donne à lire aux moteurs comme aux IA.

Publié le 24 août 20266 min de lecture
  • seo
  • i18n
  • geo
  • inertia

Une vitrine pré-lancement a un seul travail côté visibilité : être trouvable, dans plusieurs langues, par les moteurs classiques comme par les moteurs génératifs qui répondent désormais à la place des dix liens bleus. aubia.dev est servie en six langues, en rendu serveur, avec un balisage pensé pour l'extraction. Ce dernier article de la série démonte cette mécanique. Et le blog que vous lisez tourne sur les mêmes règles.

Six locales, une URL par langue

Le site est routé sous un préfixe de locale : /fr, /en, /es, /de, /it, /pt. Chaque langue a donc sa propre URL indexable, jamais un paramètre ou un cookie qui masquerait le contenu aux robots. Un middleware résout la locale avant le routing, à partir du segment d'URL, du cookie, puis de l'en-tête Accept-Language. La racine / redirige vers la meilleure langue détectée.

Pour l'audience internationale hors zones couvertes, x-default pointe vers l'anglais. Le rendu se fait en SSR via Inertia : le HTML complet part au premier octet, ce qui est un invariant non négociable pour le SEO. Un moteur qui visite une page reçoit son contenu rendu, pas une coquille vide en attente d'hydratation JavaScript.

Ces six langues n'ont pas toutes le même statut, et j'assume la hiérarchie. Le français est ma source éditoriale, écrite à la main. L'anglais est un pivot traduit manuellement, parce que c'est la langue de mon audience internationale et qu'elle ne tolère pas l'à-peu-près. L'espagnol, l'italien, l'allemand et le portugais sont ensuite traduits automatiquement depuis ce pivot anglais. Et les copies les plus critiques, le titre du hero, le claim, les arguments clés, restent exclues de la traduction automatique et passent toujours par ma main.

Derrière ces six langues, il n'y a aucune librairie i18n tierce. La solution npm mainstream s'est révélée incompatible avec React 19 et risquait de faire fuiter de l'état entre les requêtes sous le serveur long-running d'Octane. Le moteur i18n tient donc dans un hook maison d'une cinquantaine de lignes, branché sur le partage de props natif d'Inertia, sans une seule dépendance ajoutée.

Le hreflang, dans les deux sens

Le hreflang dit à un moteur : cette page existe aussi dans ces autres langues, à ces URL précises. La règle qui fait échouer la plupart des implémentations, c'est la bidirectionnalité : chaque version doit se référencer elle-même et référencer toutes les autres. J'utilise des codes BCP47 complets, plus précis qu'un code court, pour lever l'ambiguïté de pays entre pt-PT et pt-BR.

Le sitemap dynamique produit ce maillage pour chaque page publiée, avec un lien alternatif par locale :

foreach (self::LOCALE_TO_BCP47 as $altLocale => $bcp47) {
    $altLoc = $baseUrl . '/' . $altLocale . $path;
    $xml .= '<xhtml:link rel="alternate" hreflang="' . $bcp47 . '" href="' . $altLoc . '"/>';
}

Côté page, le composant SeoHead pose les mêmes signaux dans le <head>, mais uniquement sur les pages indexables. Les pages éphémères, comme la confirmation waitlist, sont en noindex : y ajouter un canonical ou un hreflang enverrait un signal contradictoire.

Un graphe JSON-LD relié par @id

Les données structurées d'Aubia forment un seul @graph, où chaque entité se référence par un @id stable : l'Organization, le WebSite, la SoftwareApplication et la Person fondatrice se pointent mutuellement. Ce câblage aide les moteurs et les IA de recherche à comprendre que ces objets décrivent la même entité, et non quatre choses sans rapport.

const jsonLd = JSON.stringify({
    '@context': 'https://schema.org',
    '@graph': [
        { '@type': 'Organization', '@id': `${SITE_URL}/#organization`, /* ... */ },
        { '@type': 'Person', '@id': `${SITE_URL}/#founder`, /* ... */ },
        { '@type': 'WebSite', '@id': `${SITE_URL}/#website`, /* ... */ },
        ...additionalJsonLd,
    ],
});

Le point d'extension, c'est additionalJsonLd : chaque page injecte ses noeuds propres dans le graphe partagé. La landing y ajoute son bloc FAQPage. Un article de blog y ajoute son BlogPosting et son fil d'Ariane, sans toucher au socle Organization/WebSite.

Le graphe JSON-LD animé : WebSite, Person et SoftwareApplication reliés à Organization par leurs @id, avec FAQPage et BlogPosting injectés par page via additionalJsonLd.

GEO : écrire pour les moteurs qui lisent

J'ai écrit ces pages pour être citées, pas seulement classées : quand une IA répond à la place des dix liens bleus, ce qui compte est qu'elle puisse extraire un passage entier sans le déformer. Deux leviers concrets sur aubia.dev.

Le premier est un fichier public/llms.txt à la racine : un résumé structuré du produit, de son positionnement et de son statut, écrit pour être lu par une machine. Il énonce ce qu'est Aubia, sa différence de cross-review, son public et sa feuille de route, en texte brut sans bruit d'interface.

Le second est une règle d'écriture, appliquée à cet article même : des sections autosuffisantes. Chaque titre H2 ouvre sur une définition, puis développe en un bloc d'environ cent cinquante mots qui se tient seul, hors contexte de la page. Un moteur qui découpe la page en extraits doit pouvoir citer n'importe quel bloc sans que le sens s'effondre. Des listes courtes et des titres explicites finissent le travail.

La boucle méta : ce blog s'applique ses propres règles

Ce blog est lui-même la démonstration de tout ce qui précède. Il est en flat-file : chaque article est un fichier Markdown dans le repo, converti en HTML au parsing, sans base de données, fidèle au parti pris de la Phase 0. Publier un article, c'est fusionner une branche, rien de plus.

La publication est programmée par la date en front matter : le module conserve tous les articles en cache, mais filtre à la requête ceux dont la date est passée. Un article daté d'un lundi apparaît ce lundi-là, sans redéploiement, et reste invisible avant : absent de l'index, du flux RSS, du sitemap, et en 404 sur son URL.

Chaque article publié gagne son entrée de sitemap avec sa vraie date de dernière modification, ses alternatives de langue, son balisage BlogPosting et son fil d'Ariane. La même mécanique de six locales, de hreflang bidirectionnel et de graphe JSON-LD que j'ai décrite pour la landing s'applique, article par article, à ces pages.

C'était le dernier volet de la série sur la construction de la Phase 0. J'ai construit toute la Phase 0 à ce niveau de détail, et vos retours décideront de la suite. La liste d'attente est l'endroit pour la suivre de près.

Ce chantier se raconte ici, article après article. Embarquez dans l'aventure : vos retours dessineront la suite.

Rejoindre la liste d'attente