Quand un site doit servir du contenu en plusieurs langues, la tentation est de dupliquer tout le CMS par langue, ou de mettre toutes les langues dans un seul document avec un onglet par champ. Les deux options craquent dès que le site grandit. Voici la structure que j'utilise chez VeroLab avec Next.js et Sanity, et c'est celle qui fait tourner ce blog.
L'erreur la plus fréquente : des champs multilingues sur un seul document
Un pattern courant consiste à avoir un document unique avec title_es, title_en, title_fr, body_es, body_en, body_fr. Ça marche au début, mais devient ingérable dès que vous voulez publier une langue avant une autre, donner un SEO indépendant à chaque version, ou laisser un·e rédacteur·rice travailler uniquement en français sans toucher au reste. Le pattern qui passe mieux à l'échelle est un document par langue, reliés entre eux.
Le schéma : un document par langue + translationGroup
Chaque post a un champ language (es | en | fr) et un champ translationGroup, un identifiant partagé entre les trois versions du même article. Dans le schéma Sanity, ça ressemble à ça :
defineField({ name: 'language', type: 'string', options: { list: ['es', 'en', 'fr'] } }), defineField({ name: 'translationGroup', type: 'string' })
Ainsi, chaque langue est un document indépendant — avec son propre slug, son propre statut de publication et son propre flux éditorial — et translationGroup est la colle qui indique au frontend quels documents sont «la même page» dans une autre langue.
Requêtes GROQ filtrées par langue
Pour lister les posts d'une langue donnée, le filtre est direct : *[_type == "post" && language == $lang] | order(publishedAt desc). Pour le sélecteur de langue sur une page de détail, on cherche les autres documents partageant le même translationGroup : *[_type == "post" && translationGroup == $group && language != $lang]{ language, slug }.
Routes par locale dans Next.js
Avec l'App Router, la structure typique est app/[lang]/blog/[slug]/page.tsx. Dans generateStaticParams, vous générez toutes les combinaisons langue + slug en interrogeant Sanity, et sur la page vous résolvez le document en filtrant à la fois sur language et slug.current. Cela évite les collisions quand deux langues partagent le même slug par coïncidence.
SEO : hreflang sans prise de tête
Comme chaque langue est un document avec son propre slug, générer les balises hreflang se résume à une requête sur translationGroup au moment de générer les métadonnées de la page (generateMetadata), en reliant chaque version à sa vraie URL plutôt qu'en supposant un pattern fixe du type /es/, /en/, /fr/ devant le même slug.
Le sélecteur de langue côté frontend
Si un article n'a pas encore de traduction publiée dans une langue, le sélecteur n'affiche tout simplement pas cette option plutôt que de rediriger vers une 404 ou la page d'accueil. C'est une requête supplémentaire (celle sur translationGroup), mais ça évite une mauvaise expérience assez fréquente sur les sites multilingues mal conçus.
Pourquoi cette structure passe à l'échelle
Chaque langue peut être publiée, éditée ou même supprimée indépendamment sans toucher aux autres. Vous pouvez avoir un·e rédacteur·rice qui travaille uniquement en français. Et si vous ajoutez une quatrième langue demain, vous ne touchez pas au schéma des documents existants, vous ajoutez juste une option à la liste language.
Si vous montez un projet Next.js + Sanity multilingue et voulez un second avis sur l'architecture avant d'écrire du code, contactez-moi.
