1. Por qué un widget necesita una arquitectura i18n propia en ChatGPT App
En una aplicación Next.js habitual, a menudo te apoyas en la URL (/en/..., /ru/...) o en el enrutador para vincular el idioma a la ruta. En un widget de ChatGPT es más divertido: tu UI vive dentro de un iframe en un sandbox, y la URL no la controlas tú. El idioma llega como estado desde ChatGPT, por ejemplo a través de openai/locale o un hook como useOpenAiGlobal('locale'), y no de la barra de direcciones.
Se da una situación poco común. Desde el punto de vista de Next.js, tu widget es, por así decirlo, una sola página /widget, pero dentro debe saber renderizarse en cualquier idioma que diga la plataforma. El idioma hay que cambiarlo no mediante navegación, sino mediante estado. Esto te empuja automáticamente a la arquitectura «una UI, muchos diccionarios» y subraya otra vez: guardar textos en el código es un camino sin salida.
Además, en un mismo diálogo, ChatGPT puede lanzar tu App para usuarios de distintos países. No puedes «decidir una vez que la App es en ruso» y olvidarte. El widget debe poder reinicializarse fácilmente para una nueva locale sin cambiar la lógica de negocio — justamente para eso hace falta una capa i18n cuidada.
2. Principio clave: no debe haber textos en el código
Si resumimos la filosofía de la localización de UI, suena así: los componentes de React no necesitan textos reales, necesitan claves.
En lugar de:
// MAL: texto hardcodeado en el componente
<button>Elegir un regalo</button>
el widget debería verse así:
// BIEN: el componente solo conoce la clave
<button>{t('buttons.pick_gift')}</button>
Y los textos reales «Elegir un regalo» y «Pick a gift» se guardan en los diccionarios ru.json y en.json.
¿Para qué complicarlo si podríamos hacer simplemente if (locale === 'ru')?
Primero, escalabilidad. En cuanto necesites añadir un tercer idioma, los if/else se convierten en un caos. Segundo, separación de responsabilidades. El traductor o el product puede cambiar textos en archivos JSON sin tocar el código, y la persona desarrolladora puede refactorizar componentes sin arriesgarse a romper medio copy de la UI. Tercero, consistencia: una única fuente de verdad para los textos ayuda a evitar que en un botón ponga «Comprar» y en otro «Pagar» solo porque quienes escribieron los componentes lo llamaron según su estado de ánimo.
En el mundo de ChatGPT App esto es especialmente útil: a veces querrás generar traducciones con un LLM y luego añadirlas a los diccionarios. Guardar todos los textos en archivos JSON es mucho más cómodo que esparcirlos por los componentes.
3. Estructuramos los diccionarios para el widget GiftGenius
Seguimos desarrollando nuestra aplicación didáctica GiftGenius — un widget para encontrar regalos. Ya necesitamos al menos dos idiomas: ru y en. Creamos una estructura básica:
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
Contenido más simple del diccionario 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."
}
}
Y el correspondiente locales/ru/widget.json:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Destinatario",
"placeholder": "¿Para quién buscamos el regalo?"
},
"budget": {
"label": "Presupuesto",
"placeholder": "Por ejemplo, 50"
}
},
"buttons": {
"pick_gift": "Encontrar regalos",
"try_again": "Intentar de nuevo"
},
"errors": {
"no_gifts": "No se han encontrado regalos para tus criterios."
}
}
Fíjate en que la estructura de claves es idéntica para ambos idiomas. Esto es crítico: los componentes dependen de las claves, no de los textos concretos. Si en un idioma olvidas añadir errors.no_gifts, obtendrás un error claro, no una UI a medio traducir.
En un proyecto real, es razonable dividir los diccionarios por áreas: widget, checkout, errors, etc. En la app de ejemplo basta con un archivo por idioma para no complicar.
4. De dónde obtener la locale en el widget de Apps SDK
En una aplicación de navegador clásica, irías a navigator.language. En un widget de ChatGPT puedes hacerlo, pero no hace falta: ChatGPT ya ha calculado la locale preferida del usuario y la pasa en el contexto del Apps SDK. Puede ser un campo locale en window.openai, que puedes leer directamente o a través de un hook cómodo como useOpenAiGlobal('locale').
Típico en los starters del Apps SDK es que tengas un componente raíz del widget con los datos globales de ChatGPT. Por ejemplo:
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
El ejemplo de arriba es ilustrativo; el API exacto depende de la versión del SDK, pero la idea general es correcta: locale es una verdad externa que llega desde ChatGPT, no desde el navegador del usuario.
La región (userLocation) también se pasa a través de _meta["openai/userLocation"]. Nos hará falta más adelante cuando formateemos precios y tengamos en cuenta la divisa. Para los textos basta con locale — normalmente llega en formato BCP‑47 (en, en-US, ru-RU, etc.).
5. Escribimos una capa i18n mínima: contexto + hook useT
Para que el widget sea autosuficiente y no se convierta en un manual de react-i18next, implementaremos una capa i18n ligera propia. Para un widget pequeño de ChatGPT es más que suficiente, y los principios son los mismos que en las bibliotecas populares.
Primero definimos los tipos y creamos el contexto en 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);
Ahora creamos el provider, que recibe la locale y el diccionario:
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>
);
}
Lo más interesante — el hook useT, que obtendrá los textos por clave:
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 };
}
Admitimos claves anidadas del tipo forms.recipient.label y, si falta una traducción, devolvemos la propia clave — es más útil que mostrar vacío sin más.
6. Integramos el provider i18n en el componente raíz del widget
Antes ya vimos GiftWidgetRoot, que simplemente leía locale de useOpenAiGlobal. Ahora usaremos I18nProvider en ese componente raíz y añadiremos la carga del diccionario. Supongamos que antes se veía más o menos así:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* formularios y resultados */}
</div>
);
}
Añadimos la carga del diccionario y el provider. Para simplificar usamos require/import síncronos según la locale, pero en Next.js 16 puedes usar importación asíncrona (con dynamic import) si los diccionarios son grandes.
"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>
);
}
El componente GiftWidget ahora no piensa en idiomas en absoluto, solo sabe que existe una función 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>
{/* resto del UI */}
</div>
);
}
Si mañana ChatGPT crea el widget con locale = "de-DE", podrás añadir locales/de/widget.json y una línea en loadMessages sin tocar el resto del código. Justo para esto montamos todo.
7. Formatos localizables: números, fechas, divisas
Ya hemos llevado los textos a diccionarios y envuelto el widget en I18nProvider. Pero los textos son solo la mitad de la UX: un usuario de EE. UU. espera ver 12/31/2025, y un usuario de Alemania — 31.12.2025. Lo mismo con los números y las divisas. Mostrar a un usuario de Rusia el precio «1,234.56 USD» es una buena forma de dejar claro que tu «asistente inteligente» en realidad no es muy cuidadoso.
Por suerte, en el navegador (y en el sandbox de ChatGPT) está disponible el API estándar Intl. Añadimos en i18n.tsx un par de utilidades que usen la locale actual:
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 };
}
Ahora, en el componente donde mostramos el presupuesto o los precios de los regalos (supongamos que ya nos llegan desde el servidor MCP con 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 quieres hacer el formateo aún «más inteligente» (por ejemplo, elegir la divisa en función de userLocation), puedes combinar locale y región. Arquitectónicamente, sigue la misma línea que ya comentaste para MCP‑Gateway: locale influye en el idioma del texto, userLocation en las reglas de negocio y la divisa.
8. Reacción al cambio de idioma: qué pasa si ChatGPT cambia la locale al vuelo
En la web normal, el usuario pulsa «EN / RU» y sabes con claridad cuándo cambiar de idioma. En ChatGPT App, el modelo puede, en teoría, decidir que al usuario le conviene otro idioma (o el usuario cambia el idioma de la interfaz en ajustes), y openai/locale cambia.
Si el SDK te da una señal reactiva (por hook o evento), el patrón de código sería:
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Aquí loadMessages se reejecutará cuando cambie locale, y toda la UI se volverá a renderizar automáticamente con las nuevas traducciones. En la mayoría de escenarios reales la locale es estable durante la sesión, pero aun así es útil preparar un modelo reactivo correcto.
9. Un poco sobre cadenas complejas: placeholders y pluralización
Ya resolvimos la reactividad según locale. La siguiente pregunta natural: ¿qué hacemos con partes dinámicas del texto — cantidades, nombres, etc.? En una app de regalos podría ser algo como «Se han encontrado 3 regalos para Masha».
La manera más simple de lidiar con este tipo de frases es admitir placeholders en t() y sustituir valores al vuelo. Para ello, modificamos useT para que acepte como segundo argumento un objeto de valores:
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 };
}
Ahora añadimos una cadena en widget.json:
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
Y la usamos:
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
Con la pluralización, puedes proceder de varias formas: o bien crear varias claves (one, few, many) y elegirlas manualmente, o bien conectar una biblioteca como react-intl/i18next, que tienen soporte completo de reglas de plurales (plural rules). Para un widget didáctico, la elección manual por rangos (por ejemplo, if count === 1, if count < 5, etc.) es perfectamente aceptable.
10. Dónde ubicar i18n en la estructura del template de Next.js del Apps SDK
Desde el punto de vista de Next.js 16 y del template oficial del Apps SDK, tu widget suele ser un punto de entrada especializado en app/ (por ejemplo, app/widget/page.tsx o un componente aparte que el Apps SDK renderiza dentro de ChatGPT).
Patrón típico:
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
La capa i18n vive completamente en la parte cliente — todo lo que hemos escrito arriba son client components. Importante: en el entorno de ChatGPT todo se renderiza en el cliente dentro de un iframe, por lo que los patrones SSR clásicos de i18n (HTML localizado en el servidor) se pueden dejar a un lado por ahora. Esto simplifica mucho la vida: trabajas como con un SPA normal, solo que en lugar de navigator.language usas openai/locale.
Si necesitas compartir traducciones entre varios widgets de una misma App (por ejemplo, el asistente principal y «un pequeño widget inline»), puedes extraer I18nProvider a un módulo aparte y reutilizarlo.
11. Mini pruebas de la localización
En cuanto aparece una capa i18n en el sistema, merece la pena empezar a probarla por separado — de lo contrario, cualquier errata en una clave se convierte en una «UI a medio traducir». Ya que hemos montado la arquitectura, vale la pena verificarla.
Primero, tiene sentido escribir tests unitarios sencillos para loadMessages y useT (con React Testing Library o incluso sin React — probando directamente la función t). Estos tests cazan erratas en claves y ayudan si tú o la persona traductora borráis accidentalmente una rama necesaria del diccionario.
Segundo, es útil prever un modo de «ejecución local» del widget fuera de ChatGPT, donde puedas forzar la locale mediante un parámetro de query o un botón en la UI. Esto es útil tanto para ti como para QA: nadie está obligado a levantar todo el Dev Mode y ChatGPT solo para ver cómo queda la traducción al alemán. Con estos tests básicos y ejecución local con distintas locale, podrás evolucionar con más tranquilidad la UI y los textos, y después pasar a la localización de las descripciones de tools.
Cómo se relaciona todo esto con el comportamiento del modelo
Profundizaremos en la localización de las descriptions de las herramientas en la próxima lección, pero ya ahora es importante ver la conexión: el widget y las herramientas deben hablar el mismo idioma que el usuario. Ya estás construyendo una UI que se adapta a openai/locale. El servidor MCP, con esa misma señal, elige el catálogo y los textos correctos. Es lógico que la descripción de suggest_gifts y los campos recipient, budget se expliquen al modelo en el idioma del usuario — reducirá llamadas raras a tools y argumentos incorrectos.
Es decir, la arquitectura i18n del widget no es solo cosmética. Es el primer ladrillo de un sistema común en el que la capa de UI, la capa MCP y el modelo usan el mismo contexto de locale.
12. Errores típicos al localizar widgets
Error n.º 1: textos hardcodeados directamente en JSX.
Historia muy común: el widget comenzó como un prototipo rápido en un solo idioma y de repente llega «necesitamos también inglés». Al final la UI está llena de cadenas en ruso, y el intento de añadir inglés se convierte en un buscar-y-reemplazar global por el proyecto. Cuanto antes introduzcas diccionarios y la función t(), menos problemas tendrás luego.
Error n.º 2: if (locale === 'ru') en cada esquina.
A veces parece una «solución rápida», pero se rompe en cuanto aparece un tercer idioma o variantes como ru-RU, ru, ru-UA. Mejor escribir una vez loadMessages(locale) con normalización (locale.split('-')[0]) y olvidarte del tema, que esparcir comprobaciones por todo el código.
Error n.º 3: mezclar la lógica de negocio con los textos.
A veces se crean en los componentes condiciones complejas que resuelven a la vez el ramificado de negocio y la elección del texto. Por ejemplo, «si no hay regalos, mostrar esta frase, y si el presupuesto es bajo, otra». Cambiar el copy se vuelve difícil, la lógica se desparrama y las traducciones se meten en TypeScript. Mucho mejor cuando los componentes devuelven al diccionario solo la clave (errors.no_gifts, errors.budget_too_low) y los textos se editan aparte.
Error n.º 4: no formatear fechas/divisas según la locale.
Mostrar a un usuario en Alemania el precio $1,234.56 en lugar de 1.234,56 $ no es un bug, es un anti‑patrón de UX. Pero los usuarios lo perciben como «este servicio no está hecho para mí». Es muy fácil olvidarse de Intl.NumberFormat y Intl.DateTimeFormat si estás acostumbrado a un solo país. Por eso es útil extraer los formateadores a un hook como useFormatters() y usarlos siempre en lugar de concatenaciones manuales.
Error n.º 5: no contemplar un posible cambio de locale.
Algunas personas desarrolladoras leen locale una sola vez al montar y la consideran constante. En la mayoría de casos funcionará, pero si ChatGPT o la plataforma cambian la locale (por ejemplo, el usuario cambia el idioma de la interfaz), tu widget se quedará en el idioma anterior. Es mejor tratar locale como parte del estado reactivo y vincularla con useMemo/useEffect.
Error n.º 6: mantener estructuras de diccionario distintas por idioma.
A veces un idioma lo traduce una persona y otro idioma otra, y como resultado widget.en.json y widget.ru.json divergen en estructura. En uno existe forms.budget.placeholder, en otro solo forms.budget.label. En runtime esto acaba en undefined y errores raros. Mantén siempre un archivo «canónico» (normalmente el inglés) del que el resto heredan la estructura. Para generar nuevos diccionarios incluso puedes escribir scripts que comprueben la correspondencia de claves.
Error n.º 7: intentar resolverlo todo de entrada con un framework i18n pesado.
Soluciones populares como react-i18next o next-intl son potentes y útiles, pero para un widget pequeño de ChatGPT pueden ser excesivas. A menudo es más sencillo empezar con tu capa ligera (I18nProvider, useT, diccionarios en JSON) y, ya con el crecimiento de la aplicación, migrar a una biblioteca completa si realmente necesitas pluralizaciones complejas, formato ICU, etc.
GO TO FULL VERSION