1. Por que o widget precisa de uma arquitetura i18n própria no ChatGPT App
Em um aplicativo Next.js comum, você geralmente se baseia na URL (/en/..., /ru/...) ou no roteador para vincular o idioma à rota. No widget do ChatGPT é mais divertido: sua UI vive dentro de um iframe em sandbox, e a URL não é controlada por você. O idioma chega como estado do ChatGPT, por exemplo via openai/locale ou um hook como useOpenAiGlobal('locale'), e não a partir da barra de endereços.
Isso cria uma situação incomum. Do ponto de vista do Next.js, seu widget — é, grosso modo, uma única página /widget, mas por dentro ele deve ser capaz de se renderizar em qualquer idioma que a plataforma indicar. A troca de idioma precisa acontecer não por navegação, mas por estado. Isso naturalmente empurra para uma arquitetura “um UI, muitos dicionários” e reforça novamente: manter strings no código é um beco sem saída.
Além disso, no mesmo diálogo o ChatGPT pode executar seu App para usuários de países diferentes. Você não pode “decidir uma vez que o App é em russo” e esquecer. O widget deve ser facilmente reinicializado para um novo locale, sem mexer na lógica de negócio — é exatamente para isso que serve uma camada i18n caprichada.
2. Princípio principal: não deve haver strings no código
Resumindo a filosofia da localização de UI, ela soa assim: componentes React não precisam de textos reais, eles precisam de chaves.
Em vez de:
// RUIM: string hardcoded no componente
<button>Escolher um presente</button>
o widget deve ficar assim:
// BOM: o componente conhece apenas a chave
<button>{t('buttons.pick_gift')}</button>
E os textos reais “Escolher um presente” e “Pick a gift” ficam nos dicionários ru.json e en.json.
Por que toda essa complicação, se poderíamos simplesmente if (locale === 'ru')?
Primeiro, escalabilidade. Assim que você precisar adicionar um terceiro idioma, if/else vira uma bagunça. Segundo, separação de responsabilidades. Um tradutor ou product pode alterar os textos nos arquivos JSON sem tocar no código, e o desenvolvedor pode refatorar componentes sem risco de quebrar metade do copy da UI. Terceiro, uniformidade: uma fonte única da verdade para os textos ajuda a evitar a situação em que, em um botão, está “Comprar”, e em outro — “Pagar”, apenas porque autores diferentes nomearam conforme o humor.
No mundo do ChatGPT App isso é especialmente útil: às vezes você vai querer gerar traduções via LLM e depois adicioná-las aos dicionários. Manter todos os textos em arquivos JSON é bem mais conveniente do que espalhá-los pelos componentes.
3. Estruturando os dicionários para o widget GiftGenius
Vamos continuar evoluindo nosso app didático GiftGenius — um widget de seleção de presentes. Já precisamos de pelo menos dois idiomas: ru e en. Crie a estrutura base:
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
Conteúdo mais simples do dicionário 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."
}
}
E o correspondente locales/ru/widget.json:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Destinatário",
"placeholder": "Para quem estamos procurando o presente?"
},
"budget": {
"label": "Orçamento",
"placeholder": "Por exemplo, 50"
}
},
"buttons": {
"pick_gift": "Encontrar presentes",
"try_again": "Tentar novamente"
},
"errors": {
"no_gifts": "Nenhum presente foi encontrado para seus critérios."
}
}
Note que a estrutura das chaves é idêntica para ambos os idiomas. Isso é crítico: os componentes se baseiam nas chaves, não em textos específicos. Se em um idioma você esquecer de adicionar errors.no_gifts, receberá um erro claro, e não uma UI parcialmente traduzida.
Em um projeto real, faz sentido dividir os dicionários por áreas: widget, checkout, errors etc. No app didático, basta um arquivo por idioma para não complicar.
4. De onde obter a locale no widget do Apps SDK
Em um aplicativo de navegador clássico, você olharia para navigator.language. No widget do ChatGPT dá para fazer isso, mas não é necessário: o ChatGPT já determinou a localidade preferida do usuário e a fornece no contexto do Apps SDK. Pode ser o campo locale em window.openai, que pode ser lido diretamente ou via um hook conveniente como useOpenAiGlobal('locale').
Em starters do Apps SDK, normalmente você tem um componente raiz do widget, no qual os dados globais do ChatGPT estão disponíveis. Algo como:
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
O exemplo acima é ilustrativo; a API exata depende da versão do SDK, mas a ideia geral é correta: locale — é a verdade externa, vinda do ChatGPT, e não do navegador do usuário.
A região (userLocation) também é passada via _meta["openai/userLocation"]. Precisaremos dela um pouco mais adiante, quando formos formatar preços e considerar a moeda. Para textos, locale é suficiente — normalmente chega no formato BCP‑47 (en, en-US, ru-RU etc.).
5. Escrevendo uma camada i18n mínima: contexto + hook useT
Para que o widget seja autônomo e não vire um tutorial de react-i18next, vamos implementar uma camada i18n leve própria. Para um widget pequeno do ChatGPT isso é mais do que suficiente, e os princípios são os mesmos das bibliotecas populares.
Primeiro, vamos definir os tipos e criar o contexto em 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);
Agora vamos fazer o provider, que recebe locale e o dicionário:
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>
);
}
A parte mais interessante é o hook useT, que buscará as strings pela chave:
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 };
}
Damos suporte a chaves aninhadas como forms.recipient.label e, caso a tradução não exista, retornamos a própria chave — isso é mais útil do que exibir vazio.
6. Integrando o provider de i18n ao componente raiz do widget
Já vimos o GiftWidgetRoot, que apenas lia locale de useOpenAiGlobal. Agora usaremos I18nProvider nesse componente raiz e adicionaremos o carregamento do dicionário. Suponha que antes ele se parecia com isto:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* formulários e resultados */}
</div>
);
}
Vamos adicionar o carregamento do dicionário e o provider. Para simplificar, use um require/import síncrono por locale, mas no Next.js 16 você pode usar import assíncrono (com dynamic import), se os dicionários forem 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>
);
}
O componente GiftWidget agora não pensa mais em idiomas; ele só sabe que existe a função 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 da UI */}
</div>
);
}
Se amanhã o ChatGPT criar o widget com locale = "de-DE", você poderá adicionar locales/de/widget.json e uma linha em loadMessages, sem tocar no restante do código. É para isso que fizemos tudo isso.
7. Formatos localizáveis: números, datas, moedas
Já colocamos os textos nos dicionários e envolvemos o widget com I18nProvider. Mas textos são só metade da UX: um usuário dos EUA espera ver 12/31/2025, enquanto um usuário da Alemanha — 31.12.2025. O mesmo vale para números e moedas. Mostrar a um usuário na Rússia o preço “1,234.56 USD” é um bom jeito de deixar claro que seu “assistente inteligente” na verdade não é muito atento.
Felizmente, no navegador (e no sandbox do ChatGPT) está disponível a API padrão Intl. Vamos adicionar em i18n.tsx alguns utilitários que usam a locale atual:
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 };
}
Agora, no componente onde mostramos o orçamento ou os preços dos presentes (suponha que já os recebamos do servidor MCP com indicação de 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>
);
}
Se quiser deixar a formatação ainda “mais inteligente” (por exemplo, escolher a moeda com base em userLocation), é possível combinar locale e região. Arquiteturalmente isso segue a mesma linha que você já discutiu para o MCP‑Gateway: locale afeta o idioma do texto, userLocation — as regras de negócio e a moeda.
8. Reação à troca de idioma: e se o ChatGPT mudar a locale em tempo de execução
Na web comum, o próprio usuário clica em “EN / RU” e você sabe exatamente quando trocar o idioma. No ChatGPT App, o modelo pode teoricamente decidir que é mais conveniente para o usuário outro idioma (ou o usuário altera o idioma da interface nas configurações), e openai/locale muda.
Se o SDK fornece um sinal reativo (via hook ou evento), o padrão de código será algo assim:
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Aqui loadMessages será reexecutado quando locale mudar, e toda a UI será rerenderizada automaticamente com as novas traduções. Na maioria dos cenários reais, a localidade é estável dentro da sessão, mas ainda assim é útil ter o modelo reativo correto.
9. Um pouco sobre strings complexas: placeholders e pluralização
Com a reatividade por locale resolvida, surge a próxima questão natural: o que fazer com partes dinâmicas do texto — quantidades, nomes etc.? No app de presentes isso pode ser algo como “Encontrados 3 presentes para Masha”.
A maneira mais simples de lidar com frases assim é dar suporte a placeholders em t() e inserir os valores em tempo real. Para isso, vamos modificar useT para aceitar como segundo argumento um 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 };
}
Agora, adicione uma string em widget.json:
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
E use-a:
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
Com pluralização há várias opções: criar várias chaves (one, few, many) e escolhê-las manualmente, ou usar uma biblioteca como react-intl/i18next, que tem suporte completo às regras de plural. Para um widget didático, a escolha manual por faixas (por exemplo, if count === 1, if count < 5 etc.) é perfeitamente aceitável.
10. Onde colocar o i18n na estrutura do template do Next.js do Apps SDK
Do ponto de vista do Next.js 16 e do template oficial do Apps SDK, seu widget costuma ser um entrypoint especializado em app/ (por exemplo, app/widget/page.tsx ou um componente separado que o Apps SDK renderiza dentro do ChatGPT).
Padrão típico:
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
A camada de i18n vive totalmente no lado do cliente — tudo o que escrevemos acima são client components. Importante: no ambiente do ChatGPT tudo já é renderizado no cliente dentro de um iframe, então os padrões clássicos de SSR‑i18n (HTML localizado no servidor) podem ser deixados de lado por enquanto. Isso simplifica bastante a vida: você trabalha como em um SPA comum, só que em vez de navigator.language usa openai/locale.
Se você precisar compartilhar traduções entre vários widgets de um mesmo App (por exemplo, o assistente principal e um “mini widget inline”), pode extrair I18nProvider para um módulo separado e reutilizá-lo.
11. Mini testes de localização
Assim que surgir uma camada i18n no sistema, vale começar a testá-la separadamente — do contrário, qualquer erro de digitação numa chave vira “UI parcialmente traduzida”. Já que criamos a arquitetura, nada mais justo do que testá-la.
Em primeiro lugar, faz sentido escrever testes unitários simples para loadMessages e useT (usando React Testing Library ou até sem React — apenas testando a função t). Esses testes capturam erros de digitação nas chaves e ajudam se você ou o tradutor apagarem acidentalmente um ramo necessário do dicionário.
Em segundo lugar, é conveniente prever um modo de “execução local” do widget fora do ChatGPT, no qual você possa forçar a locale via parâmetro de query ou um botão na UI. Isso é útil para você e para o QA: ninguém é obrigado a subir todo o Dev Mode e o ChatGPT só para ver como fica a tradução em alemão. Com esses testes básicos e a execução local em diferentes locale, você ficará muito mais tranquilo para evoluir a UI e os textos, e depois passar à localização das descrições dos tools.
Como tudo isso se relaciona com o comportamento do modelo
Vamos entrar a fundo na localização das descriptions dos instrumentos na próxima lição, mas já agora é importante ver a ligação: o widget e os instrumentos devem falar o mesmo idioma do usuário. Você já está construindo uma UI que se adapta a openai/locale. O servidor MCP, com base nesse mesmo sinal, escolhe o catálogo e os textos corretos. Faz sentido que a descrição de suggest_gifts e os campos recipient, budget sejam explicados ao modelo no idioma do usuário — isso reduzirá chamadas estranhas de ferramentas e argumentos incorretos.
Ou seja, a arquitetura i18n do widget — não é apenas cosmética. É o primeiro tijolo de um sistema maior, onde a camada de UI, a camada MCP e o modelo usam o mesmo contexto de localidade.
12. Erros comuns na localização de widgets
Erro nº 1: strings hardcoded diretamente no JSX.
História muito comum: o widget começou como um protótipo rápido em um único idioma e, de repente, “precisamos também de inglês”. O resultado é uma UI crivada de strings em russo, e tentar adicionar inglês vira um localizar/substituir global no projeto. Quanto antes você criar os dicionários e a função t(), menos problemas terá adiante.
Erro nº 2: if (locale === 'ru') em todo canto.
Essa condição às vezes parece uma “solução rápida”, mas quebra imediatamente assim que aparece um terceiro idioma ou variantes como ru-RU, ru, ru-UA. É melhor escrever uma vez loadMessages(locale) com normalização (locale.split('-')[0]) e não pensar mais nisso, do que espalhar checagens pelo código.
Erro nº 3: misturar lógica de negócio e textos.
Às vezes os desenvolvedores criam condições complexas nos componentes que resolvem simultaneamente o fluxo de negócio e a escolha do texto. Por exemplo, “se não houver presentes, mostrar esta frase; se o orçamento for baixo — outra”. No fim, fica difícil mudar o copy, a lógica se espalha e as traduções vazam para o TypeScript. Muito melhor quando os componentes fornecem aos dicionários apenas a chave (errors.no_gifts, errors.budget_too_low), e os textos são editados separadamente.
Erro nº 4: não formatar datas/moedas de acordo com a localidade.
Mostrar a um usuário na Alemanha o preço $1,234.56 em vez de 1.234,56 $ — não é bug, é um anti‑padrão de UX. Mas os usuários percebem isso como “este serviço não foi feito para mim”. É muito fácil esquecer de Intl.NumberFormat e Intl.DateTimeFormat se você está acostumado a viver em uma única região. Por isso é útil extrair os formatadores para um hook como useFormatters() e sempre usá-los em vez de concatenar strings manualmente.
Erro nº 5: não considerar a possível troca de locale.
Alguns desenvolvedores leem locale uma única vez na montagem e depois o tratam como constante. Na maioria dos casos isso vai funcionar, mas se o ChatGPT ou a plataforma realmente mudar a localidade (por exemplo, o usuário trocou o idioma da interface), seu widget ficará no idioma antigo. O correto é tratar locale como parte do estado reativo e amarrá-lo a useMemo/useEffect.
Erro nº 6: manter estruturas de dicionário diferentes para cada idioma.
Às vezes a tradução de um idioma fica com uma pessoa e a de outro, com outra — e, no fim, widget.en.json e widget.ru.json divergem na estrutura. Em um há forms.budget.placeholder, no outro — apenas forms.budget.label. Em tempo de execução isso vira undefined e erros estranhos. Sempre mantenha um arquivo “canônico” (normalmente o inglês), do qual os demais idiomas herdam a estrutura. Para gerar novos dicionários, você pode até escrever scripts que verifiquem a correspondência das chaves.
Erro nº 7: tentar resolver tudo de uma vez com um framework i18n pesado.
Soluções populares como react-i18next ou next-intl são poderosas e úteis, mas para um widget pequeno do ChatGPT podem ser excessivas. Muitas vezes é mais simples começar com uma camada própria leve (I18nProvider, useT, dicionários em JSON), e, só depois, conforme o app cresce, migrar para uma biblioteca completa se realmente precisar de pluralização avançada, formato ICU etc.
GO TO FULL VERSION