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.tsdéclarelocalePrefix: "as-needed"mais aucun préfixe n'est réellement émis : la locale est portée par le cookieNEXT_LOCALE, posé parsrc/proxy.ts(ensureLocaleCookie).- Une seule URL
boostecom.app/pricingsert les 6 langues selon le cookie. src/lib/seo/marketing-metadata.tsgénère unhreflangoù les 6 locales pointent vers la même URL → auto-référent, donc inerte pour Google.src/app/sitemap.tsn'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
| Option | Pourquoi 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 PR | Dé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'importenext-intl/navigation, etsrc/i18n/navigation.tsn'existe pas. La locale vient du cookie viagetLocale()dans le layout racine.src/i18n/routing.tsdéclare bienlocalePrefix: "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+ lesuseRouter/redirectmigrent 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.