1. Dlaczego widżet potrzebuje oddzielnej architektury i18n w ChatGPT App
W typowej aplikacji Next.js często opierasz się na URL (/en/..., /ru/...) albo routerze, aby powiązać język z trasą. W widżecie ChatGPT jest ciekawiej: twój UI żyje wewnątrz iframe w sandboxie, a adresem URL nie zarządzasz ty. Język przychodzi jako stan z ChatGPT, na przykład przez openai/locale lub hook w rodzaju useOpenAiGlobal('locale'), a nie z paska adresu.
Powstaje nietypowa sytuacja. Z punktu widzenia Next.js twój widżet to umownie jedna strona /widget, ale w środku musi umieć renderować się w dowolnym języku, który wskaże platforma. Język przełączamy nie nawigacją, lecz stanem. To automatycznie pcha w stronę architektury „jeden UI, wiele słowników” i jeszcze raz podkreśla: trzymanie tekstów w kodzie to ślepa uliczka.
Dodatkowo, w tym samym dialogu ChatGPT może uruchamiać twój App dla użytkowników z różnych krajów. Nie możesz „raz ustalić, że App jest rosyjskojęzyczny” i zapomnieć. Widżet musi łatwo się przeinicjalizować pod nowe locale, nie zmieniając logiki biznesowej – właśnie po to potrzebna jest staranna warstwa i18n.
2. Główna zasada: w kodzie nie powinno być tekstów
W skrócie filozofia lokalizacji UI brzmi tak: komponenty React nie potrzebują realnych tekstów, potrzebują kluczy.
Zamiast:
// ŹLE: tekst jest zahardkodowany w komponencie
<button>Wybierz prezent</button>
widżet powinien wyglądać tak:
// DOBRZE: komponent zna tylko klucz
<button>{t('buttons.pick_gift')}</button>
A właściwe teksty „Wybierz prezent” i „Pick a gift” przechowywane są w słownikach ru.json i en.json.
Po co to wszystko komplikować, skoro można było po prostu if (locale === 'ru')?
Po pierwsze, skalowalność. Gdy tylko trzeba dodać trzeci język, if/else zamienia się w chaos. Po drugie, separacja odpowiedzialności. Tłumacz lub product owner może zmieniać teksty w plikach JSON bez dotykania kodu, a deweloper może refaktoryzować komponenty bez ryzyka przypadkowego popsucia połowy copy w UI. Po trzecie, spójność: jeden źródłowy katalog tekstów pomaga uniknąć sytuacji, gdy na jednym przycisku jest „Kup”, a na innym „Zapłać”, tylko dlatego, że autorzy komponentów nazwali go według nastroju.
W świecie ChatGPT App jest to szczególnie przydatne: czasem zechcesz generować tłumaczenia przez LLM i potem dodawać je do słowników. Trzymanie wszystkich tekstów w plikach JSON jest znacznie wygodniejsze niż rozrzucanie ich po komponentach.
3. Strukturyzujemy słowniki dla widżetu GiftGenius
Kontynuujmy rozwój naszej aplikacji szkoleniowej GiftGenius — widżetu do doboru prezentów. Potrzebujemy co najmniej dwóch języków: ru i en. Utwórzmy bazową strukturę:
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
Najprostsza zawartość słownika 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."
}
}
I odpowiadający mu locales/ru/widget.json:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Odbiorca",
"placeholder": "Dla kogo szukamy prezentu?"
},
"budget": {
"label": "Budżet",
"placeholder": "Na przykład, 50"
}
},
"buttons": {
"pick_gift": "Znajdź prezenty",
"try_again": "Spróbuj ponownie"
},
"errors": {
"no_gifts": "Nie znaleziono prezentów dla Twoich kryteriów."
}
}
Zwróć uwagę, że struktura kluczy jest identyczna dla obu języków. To krytyczne: komponenty opierają się na kluczach, a nie na konkretnych tekstach. Jeśli w jednym języku zapomnisz dodać errors.no_gifts, dostaniesz zrozumiały błąd, a nie częściowo przetłumaczony UI.
W realnym projekcie warto dzielić słowniki na obszary: widget, checkout, errors itp. W aplikacji szkoleniowej wystarczy po jednym pliku na język, żeby nie komplikować.
4. Skąd brać locale w widżecie Apps SDK
W klasycznej aplikacji przeglądarkowej sięgnąłbyś do navigator.language. W widżecie ChatGPT tak zrobić można, ale nie trzeba: ChatGPT już wyliczył preferowaną lokalizację użytkownika i przekazuje ją do kontekstu Apps SDK. Może to być pole locale w window.openai, które można odczytać bezpośrednio lub przez wygodny hook w rodzaju useOpenAiGlobal('locale').
Typowo dla starterów Apps SDK masz korzeń widżetu, gdzie dostępne są globalne dane z ChatGPT. Umownie:
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
Powyższy przykład jest ilustracyjny; dokładne API zależy od wersji SDK, ale ogólna idea jest trafna: locale — to zewnętrzna prawda pochodząca z ChatGPT, a nie z przeglądarki użytkownika.
Region (userLocation) również jest przekazywany przez _meta["openai/userLocation"]. Przyda nam się później, gdy będziemy formatować ceny i uwzględniać walutę. Do tekstów wystarczy locale — zwykle przychodzi w formacie BCP‑47 (en, en-US, ru-RU itp.).
5. Piszemy minimalną warstwę i18n: kontekst + hook useT
Aby widżet był samowystarczalny i nie zamienił się w podręcznik do react-i18next, zaimplementujemy lekką własną warstwę i18n. Dla małego widżetu ChatGPT to w zupełności wystarczy, a zasady są te same, co w popularnych bibliotekach.
Najpierw opiszemy typy i stworzymy kontekst w 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);
Teraz stwórzmy provider, który dostaje locale i słownik:
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>
);
}
Najciekawszy jest hook useT, który będzie pobierał teksty po kluczu:
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 };
}
Obsługujemy zagnieżdżone klucze w stylu forms.recipient.label, a w razie braku tłumaczenia zwracamy sam klucz — to bardziej użyteczne niż ciche pokazanie pustego miejsca.
6. Wbudowujemy provider i18n w korzeń widżetu
Wcześniej widzieliśmy już GiftWidgetRoot, który po prostu czytał locale z useOpenAiGlobal. Teraz użyjemy I18nProvider w tym korzennym komponencie i dodamy ładowanie słownika. Załóżmy, że wcześniej wyglądał mniej więcej tak:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* formularze i wyniki */}
</div>
);
}
Dodajmy ładowanie słownika i providera. Dla prostoty użyjemy synchronicznego require/import na podstawie locale, ale w Next.js 16 możesz użyć też importu asynchronicznego (przez dynamic import), jeśli słowniki są duże.
"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>
);
}
Komponent GiftWidget nie myśli już w ogóle o językach, wie tylko, że jest funkcja 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>
{/* reszta UI */}
</div>
);
}
Jeśli jutro ChatGPT uruchomi widżet z locale = "de-DE", możesz dodać locales/de/widget.json i jedną linijkę w loadMessages, nie dotykając reszty kodu. Właśnie o to chodziło.
7. Formaty zależne od lokalizacji: liczby, daty, waluty
Teksty już przenieśliśmy do słowników i opakowaliśmy widżet w I18nProvider. Ale teksty to tylko połowa UX: użytkownik z USA oczekuje 12/31/2025, a użytkownik z Niemiec — 31.12.2025. Podobnie z liczbami i walutami. Pokazanie użytkownikowi z Rosji ceny „1,234.56 USD” to dobry sposób, by zasugerować, że wasz „inteligentny” asystent wcale nie jest zbyt uważny.
Na szczęście w przeglądarce (i w sandboxie ChatGPT) dostępne jest standardowe API Intl. Dodajmy do i18n.tsx dwie funkcje narzędziowe, które korzystają z bieżącego locale:
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 };
}
Teraz w komponencie, w którym pokazujemy budżet lub ceny prezentów (załóżmy, że już je dostajemy z serwera MCP z podaną 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>
);
}
Jeśli chcesz uczynić formatowanie jeszcze „sprytniejszym” (np. wybierać walutę na podstawie userLocation), możesz łączyć locale i region. Architektonicznie to kontynuacja linii, którą omawiałeś dla MCP‑Gateway: locale wpływa na język tekstu, a userLocation — na reguły biznesowe i walutę.
8. Reakcja na zmianę języka: co, jeśli ChatGPT zmieni locale w locie
W zwykłym webie użytkownik sam klika „EN / RU” i dokładnie wiesz, kiedy zmienić język. W ChatGPT App model może teoretycznie uznać, że użytkownikowi wygodniej w innym języku (albo użytkownik przełączy język interfejsu w ustawieniach) i openai/locale się zmieni.
Jeśli SDK daje ci reaktywny sygnał (przez hook lub zdarzenie), wzorzec w kodzie będzie taki:
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Tutaj loadMessages zostanie wywołane ponownie przy zmianie locale, a cały UI automatycznie się przerysuje z nowymi tłumaczeniami. W większości realnych scenariuszy lokalizacja jest stabilna w ramach sesji, ale warto z góry przewidzieć poprawny model reaktywny.
9. Trochę o złożonych tekstach: placeholdery i pluralizacja
Reaktywność względem locale mamy ogarniętą. Kolejne naturalne pytanie: co robić z dynamicznymi częściami tekstu — ilościami, imionami itp.? W aplikacji prezentowej może to być coś w rodzaju „Znaleziono 3 prezenty dla Maszy”.
Najprostszy sposób poradzić sobie z takimi frazami — dodać wsparcie placeholderów w t() i podstawiać wartości w locie. W tym celu zmodyfikujemy useT, aby przyjmował drugim argumentem obiekt wartości:
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 };
}
Teraz dodajmy wiersz do widget.json:
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
I użyjmy go:
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
Pluralizację można rozwiązać na różne sposoby: albo dodać kilka kluczy (one, few, many) i wybierać je ręcznie, albo podłączyć bibliotekę typu react-intl/i18next, która ma pełne wsparcie zasad liczby mnogiej (plural rules). Do aplikacji szkoleniowej ręczny wybór po zakresach (np. if count === 1, if count < 5 itd.) jest jak najbardziej akceptowalny.
10. Gdzie umieścić i18n w strukturze szablonu Apps SDK dla Next.js
Z punktu widzenia Next.js 16 i oficjalnego szablonu Apps SDK twój widżet to zwykle wyspecjalizowany entrypoint w app/ (na przykład app/widget/page.tsx lub osobny komponent, który Apps SDK renderuje wewnątrz ChatGPT).
Typowy wzorzec:
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
Warstwa i18n żyje w całości po stronie klienta — wszystko, co napisaliśmy powyżej, to client components. Ważne, że w środowisku ChatGPT i tak wszystko jest renderowane po stronie klienta wewnątrz iframe, więc klasyczne wzorce SSR‑i18n (lokalizowany HTML po stronie serwera) można na razie odłożyć. To bardzo upraszcza życie: pracujesz jak z typowym SPA, tylko zamiast navigator.language używasz openai/locale.
Jeśli potrzebujesz współdzielić tłumaczenia między kilkoma widżetami jednego App (np. główny kreator i „mały widżet inline”), możesz wynieść I18nProvider do osobnego modułu i używać ponownie.
11. Mini‑testowanie lokalizacji
Gdy tylko w systemie pojawia się warstwa i18n, warto zacząć ją testować osobno — w przeciwnym razie każda literówka w kluczu zamienia się w „częściowo przetłumaczony UI”. Skoro już zrobiliśmy architekturę, grzechem byłoby jej nie sprawdzić.
Po pierwsze, sensownie jest napisać proste testy jednostkowe dla loadMessages i useT (z użyciem React Testing Library albo nawet bez Reacta — testując po prostu funkcję t). Takie testy wyłapują literówki w kluczach i pomogą, jeśli ty lub tłumacz przypadkowo usuniecie potrzebną gałąź słownika.
Po drugie, wygodnie przewidzieć tryb „lokalnego uruchomienia” widżetu poza ChatGPT, w którym możesz wymusić locale przez parametr query lub przycisk w UI. To użyteczne i dla ciebie, i dla QA: nikt nie musi odpalać całego Dev Mode i ChatGPT tylko po to, by zobaczyć, jak wygląda niemieckie tłumaczenie. Z takimi podstawowymi testami i lokalnym przebiegiem po różnych locale o wiele spokojniej będziesz rozwijać UI i teksty, a potem przejdziesz do lokalizacji opisów tools.
Jak to się łączy z zachowaniem modelu
Głębiej wejdziemy w lokalizację opisów narzędzi w następnej lekcji, ale już teraz warto zobaczyć powiązanie: widżet i narzędzia powinny mówić językiem użytkownika. Budujesz UI, który dostosowuje się do openai/locale. Serwer MCP na podstawie tego samego sygnału wybiera właściwy katalog i teksty. Logiczne jest więc, że opis suggest_gifts oraz pola recipient, budget będą wyjaśnione modelowi w języku użytkownika — to zmniejszy liczbę dziwnych wywołań narzędzi (tool-calls) i niepoprawnych argumentów.
A więc architektura i18n widżetu to nie tylko kosmetyka. To pierwszy klocek w całym systemie, w którym warstwa UI, warstwa MCP i model używają tego samego kontekstu lokalizacji.
12. Typowe błędy przy lokalizacji widżetów
Błąd nr 1: hard‑kodowane teksty bezpośrednio w JSX.
Bardzo częsta historia: widżet zaczynano jako szybki prototyp w jednym języku, a potem nagle „potrzebny jest jeszcze angielski”. W rezultacie UI jest naszpikowany tekstami po rosyjsku, a próba dodania angielskiego zamienia się w globalne szukanie‑i‑zamienianie w projekcie. Im wcześniej wprowadzisz słowniki i funkcję t(), tym mniej problemów później.
Błąd nr 2: if (locale === 'ru') na każdym kroku.
Takie warunki czasem wydają się „szybkim rozwiązaniem”, ale natychmiast się sypią, gdy pojawia się trzeci język albo warianty typu ru-RU, ru, ru-UA. Lepiej raz napisać loadMessages(locale) z normalizacją (locale.split('-')[0]) i przestać o tym myśleć, niż rozsmarowywać warunki po całym kodzie.
Błąd nr 3: mieszanie logiki biznesowej z tekstami.
Czasem deweloperzy tworzą w komponentach złożone warunki, które jednocześnie rozwiązują rozwidlenia biznesowe i wybór tekstu. Na przykład „jeśli prezentów nie ma, pokaż tę frazę, a jeśli budżet jest mały — inną”. W efekcie trudno zmienia się copy, logika się rozłazi, a tłumaczenia wchodzą do TypeScripta. O wiele lepiej, gdy komponenty zwracają do słowników tylko klucz (errors.no_gifts, errors.budget_too_low), a teksty edytuje się osobno.
Błąd nr 4: brak formatowania dat/walut zgodnie z lokalizacją.
Pokazanie użytkownikowi w Niemczech ceny $1,234.56 zamiast 1.234,56 $ to nie bug, tylko antywzorzec UX. Użytkownicy odbierają to jako „ten serwis nie jest dla mnie”. Bardzo łatwo zapomnieć o Intl.NumberFormat i Intl.DateTimeFormat, jeśli działasz na co dzień w jednym regionie. Dlatego warto wynieść formatery do hooka w rodzaju useFormatters() i zawsze używać ich zamiast ręcznej konkatenacji napisów.
Błąd nr 5: nieuwzględnienie możliwej zmiany locale.
Niektórzy deweloperzy odczytują locale jeden raz przy montowaniu i dalej traktują je jak stałą. W większości przypadków to zadziała, ale jeśli ChatGPT lub platforma jednak zmieni lokalizację (np. użytkownik przełączy język interfejsu), twój widżet zostanie przy starym języku. Poprawniej traktować locale jako część stanu reaktywnego i powiązać z nim useMemo/useEffect.
Błąd nr 6: przechowywanie różnych struktur słowników dla różnych języków.
Czasem tłumaczenie na jeden język powierzane jest jednej osobie, na drugi — innej i w efekcie widget.en.json i widget.ru.json rozjeżdżają się strukturą. W jednym jest forms.budget.placeholder, w drugim — tylko forms.budget.label. W runtime kończy się to undefined i dziwnymi błędami. Zawsze trzymaj jeden „kanoniczny” plik (zwykle angielski), po którym pozostałe języki dziedziczą strukturę. Do generowania nowych słowników można nawet pisać skrypty, które sprawdzają zgodność kluczy.
Błąd nr 7: próba rozwiązania wszystkiego od razu ciężkim frameworkiem i18n.
Popularne rozwiązania w rodzaju react-i18next czy next-intl są mocne i przydatne, ale dla małego widżetu ChatGPT mogą być nadmiarowe. Często prościej zacząć od lekkiej własnej warstwy (I18nProvider, useT, słowniki w JSON), a dopiero później, wraz ze wzrostem aplikacji, migrować na pełnoprawną bibliotekę, jeśli naprawdę będą potrzebne złożone pluralizacje, ICU‑format itp.
GO TO FULL VERSION