ADRADR-0002 · Modèle d'URL multilingue : préfixe de locale, migration phasée

ADR-0002 — Modèle d'URL multilingue : préfixe de locale (/fr/…), migration phasée

BoostEcom sert 6 locales (en, fr, de, es, it, pt) avec un catalogue à parité parfaite (~1935 clés × 6) et ~31 000 mots déjà traduits dans le contenu marketing. Pourtant ce travail est invisible des moteurs de recherche.

Statut

Remplacé par ADR-0038 · 2026-08-12

Piliers : growth-web, security-identity, app-shell, platform-ops

Titre court d’origine : Modèle d'URL multilingue : préfixe de locale, migration phasée

Contexte

BoostEcom sert 6 locales (en, fr, de, es, it, pt) avec un catalogue à parité parfaite (~1935 clés × 6) et ~31 000 mots déjà traduits dans le contenu marketing. Pourtant ce travail est invisible des moteurs de recherche.

La cause est le modèle d'URL actuel, hérité du routing cookie-based :

  • src/i18n/routing.ts déclare localePrefix: "as-needed" mais aucun préfixe n'est réellement émis : la locale est portée par le cookie NEXT_LOCALE, posé par src/proxy.ts (ensureLocaleCookie).
  • Une seule URL boostecom.app/pricing sert les 6 langues selon le cookie.
  • src/lib/seo/marketing-metadata.ts génère un hreflang où les 6 locales pointent vers la même URL → auto-référent, donc inerte pour Google.
  • src/app/sitemap.ts n'a aucun fan-out de locale (commentaire explicite : « locale switching is cookie-based on this site »).
  • Les JSON-LD déclarent inLanguage: "en" en dur.

Conséquence mesurée : 0 URL indexable pour fr/de/es/it/pt. Un opérateur qui cherche en allemand ne trouvera jamais la version DE. Le travail de traduction ne rapporte rien en acquisition organique : seulement en UX pour les utilisateurs déjà connectés.

Contrainte structurelle découverte à la lecture du code (elle change radicalement le coût) : l'app n'a aucun segment [locale]. Les 206 pages, 19 layouts et les route groups (dashboard)/(marketing)/(minimal) vivent directement sous src/app/. Le proxy de sécurité (496 lignes) indexe des chemins sans préfixe partout : PROTECTED_PREFIXES (/account, /admin, /scan/tracking…), la redirection /auth, le gate /admin sur le rôle JWT, le gate onboarding, et le matcher. Passer au préfixe next-intl standard impose donc deux migrations couplées et à haut risque : déplacer tout l'arbre app/ sous app/[locale]/, et rendre le proxy locale-aware (sinon un chemin /fr/account n'est plus reconnu comme protégé → trou de sécurité, ou 404).

Décision

Adopter le préfixe de locale (boostecom.app/fr/pricing, localePrefix: "always" avec x-default = en), migré en 4 phases séquentielles et vérifiables, jamais en un seul changement. Chaque phase est une PR isolée, vérifiée sur preview Vercel avant merge : pas au tsc seul, parce qu'un bug de routing ne se voit pas à la compilation mais casse le site entier à l'exécution.

Statut proposed : cette décision attend une validation explicite avant d'ouvrir la phase 1, parce qu'aucune de ses étapes ne peut être vérifiée en runtime dans un environnement d'agent, elles exigent un click-through humain sur le déploiement de preview.

Alternatives écartées

OptionPourquoi non
Sous-domaines (fr.boostecom.app)Indexation propre, mais dilue l'autorité de domaine sur 6 hôtes pour un SaaS mono-produit, et ajoute une charge infra réelle (wildcard DNS, certs, cookies + auth cross-subdomain — le cookie NEXT_LOCALE et la session NextAuth deviennent cross-domain). Coût infra élevé sans bénéfice SEO supérieur au préfixe.
Statu quo (cookie)Zéro refactoring, mais laisse les ~31 000 mots traduits non-indexables indéfiniment. Le travail de traduction ne sert que l'UX connectée. C'est précisément le problème qu'on cherche à résoudre.
Query param (?lang=fr)Google ignore massivement les paramètres de langue pour le classement ; hreflang sur query strings est fragile. Non retenu par l'écosystème.
Tout migrer en une PRDéplacer 225 fichiers de route + recâbler le proxy sécurité + le SEO en un diff = non-relisable et non-vérifiable ; un défaut atterrit en prod sur le site entier. Contredit le modèle fleet (1 PR = 1 changement vérifiable).

Conséquences

État au 2026-09-05 : aucune de ces phases n'a été implémentée. Un ADR est immuable, la décision reste celle-ci, mais les phases ci-dessous se lisaient au présent alors que rien n'a bougé : src/app/ n'a pas de segment [locale] (les route groups sont toujours (marketing), (dashboard), (minimal)), aucun fichier n'importe next-intl/navigation, et src/i18n/navigation.ts n'existe pas. La locale vient du cookie via getLocale() dans le layout racine. src/i18n/routing.ts déclare bien localePrefix: "as-needed", ce qui est l'état de départ décrit ici, pas l'arrivée. À créer si la migration démarre.

Ce que ça coûte, phase par phase :

Phase 1 — Fondation routing (aucun changement d'URL visible). src/i18n/navigation.ts (à créer) = createNavigation(routing) exposant Link, redirect, usePathname, useRouter locale-aware. Reste as-needed : rien ne change pour l'utilisateur. Vérif : tsc + preview identique à aujourd'hui.

Phase 2 — Restructuration app/[locale]/. Déplacer les route groups sous src/app/[locale]/, ajouter le generateStaticParams des locales, adapter le layout racine (<html lang={locale}> depuis le param, plus depuis le cookie). C'est la phase la plus risquée : 206 pages, 19 layouts. Migration mécanique mais vérification obligatoire sur preview de : home, une page marketing, une route dashboard [orgSlug], le tunnel /auth, une page /admin.

Phase 3 — Proxy locale-aware + localePrefix: "always". Composer createMiddleware(routing) avec le proxy existant : l'intl middleware pose/redirige le préfixe en premier, puis on strippe le préfixe avant isProtected/les gates/matcher. Bascule always. Vérif preview : un /fr/account anonyme redirige bien vers /fr/auth ; /de/admin sans rôle redirige ; le cookie NEXT_LOCALE reste cohérent avec le préfixe.

Phase 4 — SEO réel. marketing-metadata.ts : hreflang pointant les URLs préfixées distinctes + x-default. sitemap.ts : fan-out ×6. JSON-LD inLanguage dynamique. og:locale recalculé. C'est ici qu'on récupère les 31 000 mots, les phases 1-3 ne sont que l'infrastructure qui rend cette phase possible.

Dette acceptée / points de vigilance :

  • Les 182 imports next/link + les useRouter/redirect migrent vers @/i18n/navigation (codemod mécanique, vérifié tsc).
  • Les URLs absolues dans le MDX et les emails ne passent pas par Link : à préfixer à la main (audit séparé).
  • Le premier paint d'un nouveau visiteur : la détection Accept-Language/geo du proxy actuel est réutilisable pour choisir la redirection de préfixe.
  • Signal de revisite : si l'autorité SEO se fragmente ou si le taux de rebond monte sur les locales préfixées, revoir vers as-needed (en sans préfixe).

Réversibilité : localePrefix se re-bascule ; le risque est contenu à condition que chaque phase soit vérifiée sur preview avant merge.

Comment c'est appliqué

Rien n'est appliqué tant que ce document est proposed. À l'acceptation, chaque phase devient une PR distincte, status passe à accepted, et le gate effectif est la vérification manuelle sur preview Vercel documentée par phase ci-dessus : pas un test automatisé, parce qu'un défaut de routing/proxy ne se capture pas au tsc ni en unit test, seulement à l'exécution du site déployé. C'est la raison pour laquelle cet ADR existe au lieu d'une PR de code : la décision doit être prise et vérifiée par un humain avec accès au navigateur.