Next.js multilingue avec next-intl : la config complète et les pièges

Cet article fait partie de la série "Refonte de site web avec l'IA". Si tu n'as pas lu le premier épisode, commence par là.
Mis à jour le 10 août 2026 : ajout de la section sur le bug de crawl découvert cinq mois après la mise en ligne.
Le problème
Mon site était 100% en français. Pas de sélecteur de langue, pas de structure multilingue, rien. Pourtant une bonne partie de mon réseau professionnel est anglophone, et mes origines brésiliennes me poussaient depuis longtemps à ajouter le portugais.
L'internationalisation d'un site Next.js App Router, c'est le chantier que tout le monde repousse. Et pour cause, ça touche absolument tout. Les routes, les layouts, les métadonnées, les composants, le contenu. Chaque fichier est impacté.
Le socle : une seule source de vérité pour le routing
Tout part d'un fichier. i18n/routing.ts déclare les langues, la langue par défaut, et surtout la table des chemins traduits.
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['fr', 'en', 'pt'],
defaultLocale: 'fr',
localePrefix: 'always',
pathnames: {
'/': '/',
'/sprint': '/sprint',
'/saas-sur-mesure-suisse': {
fr: '/saas-sur-mesure-suisse',
en: '/custom-saas-switzerland',
pt: '/saas-sob-medida-suica',
},
'/blog/[year]/[id]': '/blog/[year]/[id]',
'/tags/[tag]': '/tags/[tag]',
},
})
C'est la partie que la plupart des tutoriels sautent. Un chemin peut être identique dans les trois langues (/sprint) ou traduit (/saas-sur-mesure-suisse devient /custom-saas-switzerland en anglais). Traduire les URLs a un vrai intérêt SEO, une page anglaise qui vit sur une URL française envoie un signal contradictoire à Google.
Le localePrefix: 'always' est l'autre décision structurante. Chaque URL porte sa langue, y compris le français. Pas de racine ambiguë, pas de détection automatique qui décide à la place du visiteur. On y reviendra, parce que c'est justement la détection automatique qui m'a posé problème plus tard.
Ensuite, createNavigation génère les outils qui respectent cette table :
// i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
À partir de là, un <Link href="/saas-sur-mesure-suisse"> écrit une fois donne la bonne URL dans les trois langues, automatiquement.
La migration de toutes les routes
C'est la partie la plus délicate. Chaque page du site a dû être déplacée dans un dossier app/[locale]/. app/page.tsx est devenue app/[locale]/page.tsx, app/blog/page.tsx est devenue app/[locale]/blog/page.tsx, et ainsi de suite.
Mais ce n'est pas un simple déplacement de fichiers. Chaque page devait recevoir le paramètre locale, utiliser useTranslations() ou getTranslations() pour ses textes, et générer des métadonnées SEO dans la bonne langue.
Plus de 385 clés de traduction par langue ont été produites, soit 1 155 au total, couvrant la navigation, les pages d'offre, la home, le blog, le SEO et toute l'interface. Cohérentes entre les trois langues, pas du mot-à-mot mais une vraie localisation.
Le vrai piège : les articles traduits ont des slugs différents
Un site vitrine multilingue, c'est du contenu figé. Un blog multilingue, c'est autre chose. Un article français vit sur /fr/blog/2026/prix-saas-sur-mesure-suisse, sa version anglaise sur /en/blog/2026/custom-saas-cost-switzerland. Les slugs sont différents, et ils doivent l'être, un slug français sur une page anglaise ne se positionne sur rien.
La convention retenue : un fichier par langue, et un champ sourceId qui relie la traduction à son original.
# data/blog/2026/prix-saas-sur-mesure-suisse.mdx
id: 'prix-saas-sur-mesure-suisse'
# data/blog/2026/prix-saas-sur-mesure-suisse.en.mdx
id: 'custom-saas-cost-switzerland'
sourceId: 'prix-saas-sur-mesure-suisse'
La locale se déduit du suffixe du nom de fichier, via un champ calculé de Contentlayer. À partir de là on peut, pour n'importe quel article, retrouver ses homologues dans les autres langues, et donc générer les balises hreflang correctes.
Reste le cas des URLs croisées. Que faire quand Google, ou un visiteur, demande /en/blog/2026/prix-saas-sur-mesure-suisse, un slug français sous un préfixe anglais ? La règle appliquée : la langue demandée gagne. Si une traduction anglaise existe, on redirige vers elle. Sinon seulement, on renvoie vers la langue qui possède ce slug.
Cette règle paraît évidente écrite comme ça. Elle ne l'était pas dans mon code, j'y reviens plus bas.
Ce que l'IA m'a livré et que je n'ai pas vérifié
Claude Code a produit un AlternatePathProvider, un contexte React censé fournir au sélecteur de langue le slug traduit exact de la page courante. Propre, bien typé, avec son AlternatePathSetter pour l'alimenter depuis chaque page.
Cinq mois plus tard, en auditant le site, j'ai découvert que AlternatePathSetter n'était appelé nulle part. Le contexte était toujours vide. Le sélecteur retombait systématiquement sur son fallback, le chemin courant, et ça marchait quand même grâce à la redirection croisée décrite juste au-dessus.
Du code mort qui fonctionne par accident. C'est exactement le genre de chose qu'on ne voit pas en review quand tout a l'air correct et que le site se comporte bien. Ça n'a rien cassé, mais ça m'a appris quelque chose : quand l'IA livre une abstraction complète et élégante, vérifie qu'elle est branchée, pas seulement qu'elle est juste.
Le bug qui a bloqué l'indexation de 40 pages pendant cinq mois
Voilà la partie que je n'avais pas prévue en écrivant cet article, et de loin la plus coûteuse.
En août, la Search Console m'annonce 50 pages indexées et 84 non indexées. En creusant, deux motifs ressortent : 25 pages « Explorée, actuellement non indexée » et 7 « Détectée, actuellement non indexée ». Dans le lot, mes pages d'accueil anglaise et portugaise. Google était venu les voir, puis avait décidé de ne pas les indexer.
J'ai lancé un crawl complet du site en suivant tous les liens internes depuis le sitemap. Résultat : le crawler n'a jamais atteint une seule page /en/ ou /pt/. Pas parce qu'elles étaient cassées, elles répondaient toutes 200. Parce que rien ne pointait vers elles.
Le coupable, mon sélecteur de langue :
// Invisible pour Googlebot
<button aria-label={label} onClick={() => switchTo(code)}>
{flag}
</button>
Trois jolis drapeaux, un onClick, un router.replace. Pour un utilisateur, ça marche parfaitement. Pour un crawler, ces drapeaux n'existent pas. Googlebot n'exécute pas les gestionnaires de clic pour découvrir des URLs.
Mes 40 pages traduites n'avaient donc aucun lien entrant sur tout le site. Elles étaient absentes du sitemap, retirées à l'époque pour économiser du budget de crawl, et reliées à rien. Google ne les connaissait que par les balises hreflang, qui sont une indication et pas un lien, et qui ne transmettent aucune autorité.
Le correctif tient en quelques lignes :
// Un lien que le crawler peut suivre
<Link href={`/${code}${stripped}`} hrefLang={code} aria-label={label}>
{flag}
</Link>
Avec une subtilité qui m'a coûté une itération. Le usePathname de next-intl ne renvoie pas le chemin réel dès qu'on utilise des pathnames localisés, il renvoie le gabarit interne. Sur un article, il donne littéralement /blog/[year]/[id]. Mon premier correctif générait donc des liens href="/en/blog/[year]/[id], c'est à dire des liens cassés, en pire que le point de départ. Il faut utiliser le usePathname brut de next/navigation, retirer le préfixe de langue courant, et reconstruire l'URL soi-même.
Et la redirection croisée, dans tout ça ? Elle envoyait le visiteur vers la langue qui possède le slug, pas vers celle qu'il avait demandée. Concrètement : depuis un article anglais, cliquer sur le drapeau français ramenait à l'anglais. Le comportement passait inaperçu tant que le sélecteur n'était qu'un bouton. Transformé en lien crawlable, il devenait un signal contradictoire envoyé à Google.
Ce que je retiens
L'i18n technique, next-intl la gère très bien, et une IA la met en place en deux heures là où j'y passais une semaine. Ce n'est plus là qu'est la difficulté.
La difficulté est là où personne ne regarde. Un composant peut être accessible, testé, agréable à utiliser, et rester totalement invisible pour un moteur de recherche. Une abstraction peut être propre et n'être jamais branchée. Une redirection peut fonctionner cinq mois avant qu'on réalise qu'elle répond à côté de la question.
Trois réflexes que j'applique maintenant sur tout site multilingue :
- Chaque version linguistique doit avoir au moins un lien
<a href>entrant. Unhreflangne remplace pas un lien. - Le sitemap liste les trois langues. Économiser du budget de crawl en retirant des pages, c'est se priver du seul canal de découverte qui reste quand le maillage est défaillant.
- Crawler son propre site après chaque refonte. Trente lignes de script suffisent. Si ton crawler n'atteint pas une page, Google non plus.
Cette migration i18n, c'est typiquement le genre de fondation qu'on pose dès le début quand on construit une plateforme SaaS sur mesure. Pas une fonctionnalité à ajouter après, mais une décision d'architecture qui se paie cher quand on la prend trop tard.
Épisode suivant de la série : transformer 13 épisodes de podcast en 50 articles de blog avec l'IA.

Toni Dias
Ingénieur logiciel et partenaire technique · AsuOs
Articles liés

Créer une landing page avec l'IA : comment obtenir un résultat professionnel
Page SaaS sur mesure, page Sprint interactive, page 404 personnalisée trilingue. Claude Code a créé des landing pages complètes avec composants React, données structurées Schema.org et design responsive. Épisode 4/7.

Refonte de site web avec l'IA : retour d'expérience avec Claude Code
40 commits, 185 fichiers, 14 500 lignes de code. En 8 heures de travail, Claude Code a transformé asuos.ch de fond en comble. Premier épisode d'une série où je vous raconte tout, sans filtre.
Prêt à transformer votre business digital ?
Toni Dias vous accompagne dans votre stratégie digitale avec des solutions sur mesure.