1. Pourquoi un widget a besoin d’une architecture i18n dédiée dans ChatGPT App
Dans une application Next.js classique, vous vous appuyez souvent sur l’URL (/en/..., /ru/...) ou le routeur pour lier la langue à une route. Dans un widget ChatGPT, c’est plus amusant : votre UI vit dans un iframe en sandbox, et l’URL n’est pas sous votre contrôle. La langue arrive sous forme d’état depuis ChatGPT, par exemple via openai/locale ou un hook comme useOpenAiGlobal('locale'), et non depuis la barre d’adresse.
On obtient une situation inhabituelle. Du point de vue de Next.js, votre widget — c’est en gros une seule page /widget, mais en interne, elle doit pouvoir se rendre dans n’importe quelle langue indiquée par la plateforme. Il faut basculer la langue non pas par la navigation, mais via l’état. Cela pousse naturellement vers une architecture « une UI, plusieurs dictionnaires » et souligne encore une fois : stocker des chaînes dans le code est une voie sans issue.
De plus, dans un même dialogue, ChatGPT peut lancer votre App pour des utilisateurs de pays différents. Vous ne pouvez pas « décider une fois pour toutes que l’App est francophone » et oublier. Le widget doit pouvoir se réinitialiser facilement sur un nouveau locale sans changer la logique métier — c’est précisément à cela que sert une couche i18n soignée.
2. Principe clé : aucune chaîne en dur dans le code
Pour résumer la philosophie de la localisation UI : les composants React n’ont pas besoin de textes réels, ils ont besoin de clés.
Au lieu de :
// MAUVAIS : chaîne en dur dans le composant
<button>Choisir un cadeau</button>
le widget doit ressembler à :
// BIEN : le composant ne connaît que la clé
<button>{t('buttons.pick_gift')}</button>
Et les chaînes réelles « Choisir un cadeau » et « Pick a gift » sont stockées dans les dictionnaires ru.json et en.json.
Pourquoi toute cette complexité, alors qu’on pourrait faire simplement if (locale === 'ru') ?
Premièrement, la scalabilité. Dès que vous devez ajouter une troisième langue, le if/else devient ingérable. Deuxièmement, la séparation des responsabilités. Un traducteur ou un product manager peut modifier les textes dans des fichiers JSON sans toucher au code, et le développeur peut refactoriser les composants sans risquer de casser la moitié du copy UI. Troisièmement, la cohérence : une source de vérité unique pour les textes évite qu’un bouton dise « Acheter » et un autre « Payer » juste parce que les auteurs des composants l’ont nommé selon leur humeur.
Dans le monde des ChatGPT Apps, c’est particulièrement utile : vous voudrez parfois générer des traductions via un LLM puis les ajouter aux dictionnaires. Conserver tous les textes dans des fichiers JSON est bien plus pratique que de les éparpiller dans les composants.
3. Structurer les dictionnaires pour le widget GiftGenius
Continuons de développer notre application pédagogique GiftGenius — un widget de sélection de cadeaux. Il nous faut déjà au moins deux langues : ru et en. Créons une structure de base :
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
Contenu minimal du dictionnaire locales/en/widget.json :
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Recipient",
"placeholder": "Who is this gift for?"
},
"budget": {
"label": "Budget",
"placeholder": "For example, 50"
}
},
"buttons": {
"pick_gift": "Find gifts",
"try_again": "Try again"
},
"errors": {
"no_gifts": "No gifts found for your criteria."
}
}
Et le fichier correspondant locales/ru/widget.json :
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Destinataire",
"placeholder": "Pour qui cherchons-nous un cadeau ?"
},
"budget": {
"label": "Budget",
"placeholder": "Par exemple, 50"
}
},
"buttons": {
"pick_gift": "Trouver des cadeaux",
"try_again": "Réessayer"
},
"errors": {
"no_gifts": "Aucun cadeau ne correspond à vos critères."
}
}
Notez que la structure des clés est identique pour les deux langues. C’est crucial : les composants s’appuient sur les clés, pas sur des chaînes concrètes. Si, dans une langue, vous oubliez d’ajouter errors.no_gifts, vous obtiendrez une erreur explicite, et non une UI à moitié traduite.
Dans un projet réel, il est judicieux de diviser les dictionnaires par domaines : widget, checkout, errors, etc. Dans l’application pédagogique, un fichier par langue suffit pour ne pas compliquer.
4. D’où vient le locale dans un widget Apps SDK
Dans une application navigateur classique, vous iriez lire navigator.language. Dans un widget ChatGPT, vous pouvez, mais ce n’est pas nécessaire : ChatGPT a déjà déterminé la locale préférée de l’utilisateur et la transmet dans le contexte de l’Apps SDK. Cela peut être un champ locale dans window.openai, lisible directement ou via un hook pratique comme useOpenAiGlobal('locale').
Typiquement, pour les starters de l’Apps SDK, vous avez un composant racine du widget où sont disponibles les données globales de ChatGPT. Par exemple :
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
L’exemple ci-dessus est illustratif ; l’API exacte dépend de la version du SDK, mais l’idée générale est correcte : locale — c’est une vérité externe, fournie par ChatGPT, et non par le navigateur de l’utilisateur.
La région (userLocation) est également transmise via _meta["openai/userLocation"]. Nous en aurons besoin un peu plus tard, lorsque nous formaterons les prix et prendrons en compte la devise. Pour les textes, locale suffit — il arrive généralement au format BCP‑47 (en, en-US, ru-RU, etc.).
5. Écrire une couche i18n minimale : contexte + hook useT
Pour que le widget soit autonome et ne se transforme pas en manuel react-i18next, implémentons une fine couche i18n maison. Pour un petit widget ChatGPT, c’est largement suffisant, et les principes sont les mêmes que dans les bibliothèques populaires.
Décrivons d’abord les types et créons le contexte dans app/widget/i18n.tsx :
"use client";
import React, { createContext, useContext } from "react";
type Messages = Record<string, any>;
type I18nContextValue = {
locale: string;
messages: Messages;
};
const I18nContext = createContext<I18nContextValue | null>(null);
Créons maintenant un provider qui reçoit le locale et le dictionnaire :
type Props = {
locale: string;
messages: Messages;
children: React.ReactNode;
};
export function I18nProvider({ locale, messages, children }: Props) {
return (
<I18nContext.Provider value={{ locale, messages }}>
{children}
</I18nContext.Provider>
);
}
Le plus intéressant — le hook useT, qui ira chercher les chaînes par clé :
export function useT() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useT must be used within I18nProvider");
function t(path: string): string {
return path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages)
?? path;
}
return { t, locale: ctx.locale };
}
Nous prenons en charge des clés imbriquées du type forms.recipient.label et, en cas d’absence de traduction, nous renvoyons la clé elle‑même — c’est plus utile que d’afficher du vide.
6. Intégrer l’i18n‑provider dans le composant racine du widget
Nous avons déjà vu GiftWidgetRoot, qui lisait simplement le locale via useOpenAiGlobal. Utilisons maintenant I18nProvider dans ce composant racine et ajoutons le chargement du dictionnaire. Supposons qu’il ressemblait auparavant à ceci :
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* formulaires et résultats */}
</div>
);
}
Ajoutons le chargement du dictionnaire et le provider. Pour la simplicité, utilisons un require/import synchrone selon le locale, mais dans Next.js 16 vous pouvez aussi utiliser l’import asynchrone (dynamic import) si les dictionnaires sont volumineux.
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
import { I18nProvider } from "./i18n";
import { GiftWidget } from "./GiftWidget";
function loadMessages(locale: string) {
if (locale.startsWith("ru")) {
return require("/locales/ru/widget.json");
}
return require("/locales/en/widget.json");
}
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = loadMessages(locale);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Le composant GiftWidget ne pense désormais plus du tout aux langues, il sait seulement qu’il existe une fonction t :
"use client";
import { useT } from "./i18n";
export function GiftWidget() {
const { t } = useT();
return (
<div>
<h1>{t("title")}</h1>
<label>{t("forms.recipient.label")}</label>
{/* reste de l’UI */}
</div>
);
}
Si demain ChatGPT crée un widget avec locale = "de-DE", vous pourrez ajouter locales/de/widget.json et une ligne dans loadMessages sans toucher au reste du code. C’est pour cela que tout a été mis en place.
7. Formats localisables : nombres, dates, devises
Nous avons déjà externalisé les textes dans des dictionnaires et enveloppé le widget dans I18nProvider. Mais les textes ne représentent que la moitié de l’UX : un utilisateur américain s’attend à voir 12/31/2025, tandis qu’un utilisateur allemand verra 31.12.2025. Idem pour les nombres et les devises. Afficher à un utilisateur en Russie le prix « 1,234.56 USD » — c’est un bon moyen de montrer que votre « assistant intelligent » n’est pas très attentif.
Heureusement, dans le navigateur (et dans la sandbox ChatGPT), l’API standard Intl est disponible. Ajoutons à i18n.tsx quelques utilitaires qui utilisent le locale courant :
export function useFormatters() {
const { locale } = useT();
const formatCurrency = (value: number, currency: string) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(value);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat(locale).format(date);
return { formatCurrency, formatDate };
}
Désormais, dans le composant où nous affichons le budget ou les prix des cadeaux (supposons que nous les recevons déjà du serveur MCP avec la currency) :
import { useFormatters } from "./i18n";
type GiftCardProps = {
name: string;
price: number;
currency: string;
};
export function GiftCard({ name, price, currency }: GiftCardProps) {
const { formatCurrency } = useFormatters();
return (
<div>
<div>{name}</div>
<div>{formatCurrency(price, currency)}</div>
</div>
);
}
Si vous voulez rendre le formatage encore plus « intelligent » (par exemple, choisir la devise en fonction de userLocation), vous pouvez combiner locale et région. Architecturiellement, cela prolonge la logique déjà discutée pour MCP‑Gateway : locale influe sur la langue du texte, userLocation — sur les règles métiers et la devise.
8. Réagir au changement de langue : si ChatGPT modifie le locale à la volée
Sur le web classique, l’utilisateur clique lui‑même « EN / RU » et vous savez exactement quand changer la langue. Dans un ChatGPT App, le modèle peut théoriquement décider qu’une autre langue est plus pratique (ou l’utilisateur change la langue de l’interface dans les paramètres), et openai/locale évolue.
Si le SDK vous fournit un signal réactif (via un hook ou un événement), le pattern de code sera le suivant :
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Ici, loadMessages sera ré‑exécuté lors d’un changement de locale, et toute l’UI sera automatiquement re‑rendu avec les nouvelles traductions. Dans la plupart des scénarios réels, la locale est stable au sein d’une session, mais prévoir le bon modèle réactif reste utile.
9. À propos des chaînes complexes : placeholders et pluriels
Nous avons réglé la réactivité sur locale. Question suivante naturelle : que faire des parties dynamiques du texte — quantités, prénoms, etc. ? Dans une app de cadeaux, cela peut ressembler à « 3 cadeaux trouvés pour Macha ».
La manière la plus simple de gérer ces phrases est de prendre en charge des placeholders dans t() et d’injecter les valeurs à la volée. Pour cela, modifions useT afin qu’il accepte en second argument un objet de valeurs :
type Values = Record<string, string | number>;
export function useT() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useT must be used within I18nProvider");
function t(path: string, values?: Values): string {
let text =
path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages) ??
path;
if (values) {
Object.entries(values).forEach(([key, value]) => {
text = text.replace(`{{${key}}}`, String(value));
});
}
return text;
}
return { t, locale: ctx.locale };
}
Ajoutons maintenant une chaîne dans widget.json :
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
Et utilisons‑la :
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
Pour la pluralisation, plusieurs approches : soit créer plusieurs clés (one, few, many) et les choisir manuellement, soit brancher une bibliothèque comme react-intl/i18next, qui gère pleinement les règles de pluriel. Pour un widget pédagogique, un choix manuel par plages (par exemple, if count === 1, if count < 5, etc.) est tout à fait acceptable.
10. Où placer l’i18n dans la structure du template Next.js de l’Apps SDK
Du point de vue de Next.js 16 et du template officiel de l’Apps SDK, votre widget est généralement un point d’entrée spécialisé dans app/ (par exemple, app/widget/page.tsx ou un composant séparé que l’Apps SDK rend dans ChatGPT).
Pattern typique :
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
La couche i18n vit entièrement côté client — tout ce que nous avons écrit ci‑dessus, ce sont des client components. Important : dans l’environnement ChatGPT, tout est de toute façon rendu côté client dans un iframe, donc les patterns i18n SSR classiques (HTML localisé côté serveur) peuvent être mis de côté pour l’instant. Cela simplifie grandement la vie : vous travaillez comme avec un SPA classique, sauf qu’au lieu de navigator.language, vous utilisez openai/locale.
Si vous avez besoin de partager des traductions entre plusieurs widgets d’une même App (par exemple, un assistant principal et un « petit widget inline »), vous pouvez extraire I18nProvider dans un module séparé et le réutiliser.
11. Mini‑tests de la localisation
Dès qu’une couche i18n apparaît dans le système, il faut commencer à la tester séparément — sinon n’importe quelle faute de frappe dans une clé se transforme en « UI à moitié traduite ». Puisque nous avons conçu l’architecture, autant la vérifier.
Premièrement, il est pertinent d’écrire de petits tests unitaires pour loadMessages et useT (avec React Testing Library, ou même sans React — en testant simplement la fonction t). Ces tests interceptent les fautes de frappe dans les clés et vous aideront si vous ou le traducteur supprimez par erreur une branche du dictionnaire.
Deuxièmement, il est pratique de prévoir un mode « exécution locale » du widget hors de ChatGPT, où vous pourrez forcer le locale via un paramètre de requête ou un bouton dans l’UI. C’est utile pour vous comme pour le QA : personne n’est obligé de lancer tout le Dev Mode et ChatGPT juste pour voir à quoi ressemble la traduction allemande. Avec ces tests de base et l’exécution locale sur différents locale, vous serez bien plus serein pour faire évoluer l’UI, les textes et, plus tard, passer à la localisation des descriptions des tools.
Quel rapport avec le comportement du modèle
Nous irons en profondeur sur la localisation des descriptions d’outils dans la prochaine leçon, mais il est déjà important de voir le lien : le widget et les outils doivent parler la même langue que l’utilisateur. Vous construisez déjà une UI qui s’adapte à openai/locale. Le serveur MCP, à partir de ce même signal, choisit le bon catalogue et les bons textes. Logiquement, la description de suggest_gifts et les champs recipient, budget seront expliqués au modèle dans la langue de l’utilisateur — cela réduira le nombre d’appels d’outils étranges et d’arguments incorrects.
Autrement dit, l’architecture i18n du widget n’est pas qu’un aspect cosmétique. C’est la première brique d’un système global où la couche UI, la couche MCP et le modèle utilisent le même contexte de locale.
12. Erreurs typiques lors de la localisation des widgets
Erreur n° 1 : des chaînes codées en dur directement dans le JSX.
Histoire très fréquente : le widget a démarré comme un prototype rapide dans une seule langue, puis soudain « il faut aussi l’anglais ». Résultat : l’UI est truffée de chaînes en russe, et tenter d’ajouter l’anglais se transforme en recherche/remplacement globale sur le projet. Plus tôt vous mettrez en place des dictionnaires et une fonction t(), moins vous aurez de problèmes par la suite.
Erreur n° 2 : if (locale === 'ru') partout.
Cette condition peut sembler « une solution rapide », mais elle casse immédiatement dès qu’une troisième langue ou des variantes comme ru-RU, ru, ru-UA apparaissent. Mieux vaut écrire une bonne fois loadMessages(locale) avec normalisation (locale.split('-')[0]) et ne plus y penser, plutôt que d’éparpiller des vérifications dans tout le code.
Erreur n° 3 : mélanger logique métier et textes.
Parfois, les développeurs ajoutent dans les composants des conditions complexes qui gèrent à la fois les branches métier et le choix du texte. Par exemple, « s’il n’y a pas de cadeaux, afficher telle phrase, et si le budget est bas — une autre ». Au final, modifier le copy devient difficile, la logique se disperse, et les traductions se retrouvent dans le TypeScript. Il est bien meilleur que les composants ne renvoient au dictionnaire qu’une clé (errors.no_gifts, errors.budget_too_low) et que les textes soient édités séparément.
Erreur n° 4 : absence de formatage des dates/devises selon la locale.
Afficher à un utilisateur en Allemagne le prix $1,234.56 au lieu de 1.234,56 $ — ce n’est pas un bug, mais un anti‑pattern UX. Les utilisateurs le perçoivent comme « ce service n’est pas fait pour moi ». Il est très facile d’oublier Intl.NumberFormat et Intl.DateTimeFormat si vous avez l’habitude d’un seul format régional. D’où l’intérêt d’extraire les formatteurs dans un hook comme useFormatters() et de toujours les utiliser au lieu d’une concaténation manuelle de chaînes.
Erreur n° 5 : ne pas tenir compte d’un éventuel changement de locale.
Certains développeurs lisent locale une fois au montage et le considèrent ensuite comme une constante. Le plus souvent, cela fonctionnera, mais si ChatGPT ou la plateforme change tout de même la locale (par exemple, l’utilisateur a changé la langue de l’interface), votre widget restera dans l’ancienne langue. Il est plus correct de considérer locale comme une partie de l’état réactif et de lier à celui‑ci useMemo/useEffect.
Erreur n° 6 : conserver des structures de dictionnaires différentes selon les langues.
Parfois, un traducteur gère une langue, un autre une autre, et au final widget.en.json et widget.ru.json divergent dans leur structure. Dans l’un, il y a forms.budget.placeholder, dans l’autre — seulement forms.budget.label. À l’exécution, cela se traduit par des undefined et des erreurs étranges. Gardez toujours un fichier « canonique » (souvent l’anglais) dont les autres langues héritent la structure. Pour générer de nouveaux dictionnaires, vous pouvez même écrire des scripts qui vérifient la correspondance des clés.
Erreur n° 7 : vouloir tout résoudre d’emblée avec un framework i18n lourd.
Des solutions populaires comme react-i18next ou next-intl sont puissantes et utiles, mais pour un petit widget ChatGPT, elles peuvent être excessives. Il est souvent plus simple de commencer avec une couche légère maison (I18nProvider, useT, dictionnaires en JSON), puis, lorsque l’application grandit, migrer vers une bibliothèque complète si vous avez réellement besoin de pluralisations complexes, de l’ICU format, etc.
GO TO FULL VERSION