1. Warum braucht ein Widget eine eigene i18n‑Architektur in der ChatGPT‑App
In einer gewöhnlichen Next.js‑App binden Sie die Sprache oft an die URL (/en/..., /ru/...) oder den Router. Im ChatGPT‑Widget ist es etwas anders: Ihr UI lebt in einem iframe in einer Sandbox, und die URL steuern nicht Sie. Die Sprache kommt als Zustand von ChatGPT, zum Beispiel über openai/locale oder einen Hook wie useOpenAiGlobal('locale') – nicht aus der Adresszeile.
Es entsteht eine ungewohnte Situation: Aus Sicht von Next.js ist Ihr Widget quasi eine einzige Seite /widget, aber innen muss es sich in jeder von der Plattform vorgegebenen Sprache rendern können. Der Sprachwechsel erfolgt nicht über Navigation, sondern über Zustand. Das führt automatisch zu einer Architektur „ein UI, viele Wörterbücher“ und unterstreicht nochmals: Texte im Code zu speichern ist eine Sackgasse.
Außerdem kann ChatGPT in ein und demselben Dialog Ihre App für Nutzer aus verschiedenen Ländern starten. Sie können nicht „einmal festlegen, dass die App deutschsprachig ist“ und es dabei belassen. Das Widget muss sich leicht auf eine neue locale reinitialisieren lassen, ohne die Geschäftslogik zu ändern – genau dafür braucht es eine saubere i18n‑Zwischenschicht.
2. Hauptprinzip: Strings sollten nicht im Code stehen
Kurz gesagt lautet die Philosophie der UI‑Lokalisierung: React‑Komponenten brauchen keine echten Texte, sie brauchen Schlüssel.
Stattdessen:
// SCHLECHT: Zeichenkette ist in der Komponente hartkodiert
<button>Geschenk finden</button>
muss das Widget so aussehen:
// GUT: Die Komponente kennt nur den Schlüssel
<button>{t('buttons.pick_gift')}</button>
Und die echten Strings „Geschenk auswählen“ und „Pick a gift“ werden in den Wörterbüchern ru.json und en.json gehalten.
Warum diese „Komplexität“, wenn man doch einfach if (locale === 'ru') schreiben könnte?
Erstens Skalierbarkeit. Sobald Sie eine dritte Sprache hinzufügen, wird if/else zur Brei‑Lösung. Zweitens Trennung der Verantwortlichkeiten. Übersetzer oder Product können Texte in JSON‑Dateien ändern, ohne den Code anzufassen, und Entwickler können Komponenten refaktorisieren, ohne versehentlich die Hälfte des UI‑Copy zu beschädigen. Drittens Konsistenz: Eine einheitliche Quelle der Wahrheit für Texte verhindert Situationen, in denen auf einem Button „Kaufen“ und auf einem anderen „Bezahlen“ steht – nur weil die Autoren der Komponenten nach Laune benannt haben.
Im ChatGPT‑App‑Kontext ist das besonders nützlich: Manchmal möchten Sie Übersetzungen per LLM erzeugen und anschließend in die Wörterbücher übernehmen. Alle Texte in JSON‑Dateien zu halten ist weitaus angenehmer, als sie über Komponenten zu verstreuen.
3. Wörterbücher für das GiftGenius‑Widget strukturieren
Wir entwickeln unser Lernprojekt GiftGenius weiter – ein Widget zur Geschenksuche. Mindestens zwei Sprachen brauchen wir bereits: ru und en. Legen wir die Basisstruktur an:
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
Der einfachste Inhalt des Wörterbuchs 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."
}
}
Und das entsprechende locales/ru/widget.json:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Empfänger",
"placeholder": "Für wen suchen wir ein Geschenk?"
},
"budget": {
"label": "Budget",
"placeholder": "Zum Beispiel 50"
}
},
"buttons": {
"pick_gift": "Geschenke finden",
"try_again": "Noch einmal versuchen"
},
"errors": {
"no_gifts": "Es wurden keine Geschenke gefunden, die Ihren Kriterien entsprechen."
}
}
Beachten Sie, dass die Schlüsselstruktur für beide Sprachen identisch ist. Das ist kritisch: Komponenten verlassen sich auf Schlüssel, nicht auf konkrete Strings. Wenn Sie in einer Sprache errors.no_gifts vergessen, erhalten Sie einen verständlichen Fehler statt eines halb übersetzten UIs.
In realen Projekten ist es sinnvoll, Wörterbücher nach Domänen zu trennen: widget, checkout, errors usw. Im Lernprojekt reicht pro Sprache eine Datei, um es nicht zu verkomplizieren.
4. Woher kommt die locale im Widget des Apps SDK
In einer klassischen Browser‑App würden Sie navigator.language heranziehen. Im ChatGPT‑Widget ist das möglich, aber nicht nötig: ChatGPT hat die bevorzugte Locale des Nutzers bereits ermittelt und übergibt sie im Kontext des Apps SDK. Das kann ein Feld locale in window.openai sein, das man direkt oder über einen bequemen Hook wie useOpenAiGlobal('locale') ausliest.
Typischerweise haben Sie in Apps‑SDK‑Startern eine Root‑Komponente des Widgets, in der globale Daten aus ChatGPT verfügbar sind. Sinngemäß:
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
Das obige Beispiel ist illustrativ; die genaue API hängt von der SDK‑Version ab, aber die generelle Idee stimmt: locale ist eine externe Wahrheit, die von ChatGPT kommt – nicht vom Browser des Nutzers.
Auch die Region (userLocation) wird über _meta["openai/userLocation"] übertragen. Das brauchen wir später, wenn wir Preise formatieren und die Währung berücksichtigen. Für Texte reicht locale – üblicherweise kommt sie im BCP‑47‑Format (en, en-US, ru-RU etc.).
5. Eine minimale i18n‑Schicht schreiben: Kontext + Hook useT
Damit das Widget eigenständig bleibt und nicht zum Lehrbuch über react-i18next wird, implementieren wir eine leichte eigene i18n‑Schicht. Für ein kleines ChatGPT‑Widget ist das völlig ausreichend, und die Prinzipien sind dieselben wie in populären Bibliotheken.
Zuerst beschreiben wir die Typen und erstellen den Kontext in 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);
Jetzt erstellen wir einen Provider, der locale und das Wörterbuch erhält:
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>
);
}
Das Interessanteste ist der Hook useT, der Strings per Schlüssel holt:
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 };
}
Wir unterstützen verschachtelte Schlüssel wie forms.recipient.label und geben bei fehlender Übersetzung den Schlüssel selbst zurück – das ist hilfreicher, als stumm eine Leerstelle zu zeigen.
6. Den i18n‑Provider in die Root‑Komponente des Widgets einbauen
Zuvor sahen wir bereits GiftWidgetRoot, der einfach die locale aus useOpenAiGlobal las. Nun verwenden wir I18nProvider in dieser Root‑Komponente und fügen das Laden des Wörterbuchs hinzu. Nehmen wir an, sie sah vorher so aus:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* Formulare und Ergebnisse */}
</div>
);
}
Fügen wir das Laden des Wörterbuchs und den Provider hinzu. Der Einfachheit halber verwenden wir ein synchrones require/import anhand der locale, aber in Next.js 16 können Sie auch einen asynchronen Import nutzen (über dynamic import), wenn die Wörterbücher groß sind.
"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>
);
}
Die Komponente GiftWidget denkt nun überhaupt nicht mehr über Sprachen nach, sie weiß nur, dass es eine Funktion t gibt:
"use client";
import { useT } from "./i18n";
export function GiftWidget() {
const { t } = useT();
return (
<div>
<h1>{t("title")}</h1>
<label>{t("forms.recipient.label")}</label>
{/* übrige UI */}
</div>
);
}
Wenn ChatGPT morgen ein Widget mit locale = "de-DE" startet, können Sie locales/de/widget.json hinzufügen und eine Zeile in loadMessages ergänzen, ohne den restlichen Code anzufassen. Genau dafür machen wir das alles.
7. Lokalisierbare Formate: Zahlen, Daten, Währungen
Wir haben die Texte bereits in Wörterbücher ausgelagert und das Widget mit I18nProvider umhüllt. Aber Texte sind nur die halbe UX: Ein Nutzer in den USA erwartet 12/31/2025, ein Nutzer in Deutschland 31.12.2025. Genauso bei Zahlen und Währungen. „1,234.56 USD“ einem Nutzer in Russland zu zeigen, ist ein guter Weg zu vermitteln, dass Ihr „smarter“ Assistent in Wirklichkeit nicht besonders aufmerksam ist.
Zum Glück steht im Browser (und in der ChatGPT‑Sandbox) die Standard‑API Intl zur Verfügung. Fügen wir in i18n.tsx ein paar Utilities hinzu, die die aktuelle locale verwenden:
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 };
}
Nun in der Komponente, in der wir Budget oder Preise von Geschenken anzeigen (nehmen wir an, wir erhalten sie bereits vom MCP‑Server mit Angabe der 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>
);
}
Wenn Sie das Formatieren noch „schlauer“ machen möchten (z. B. die Währung auf Basis von userLocation wählen), können Sie locale und Region kombinieren. Architektonisch setzt das die Linie fort, die Sie bereits für das MCP‑Gateway diskutiert haben: locale beeinflusst die Sprache des Textes, userLocation die Geschäftsregeln und die Währung.
8. Reaktion auf einen Sprachwechsel: Was, wenn ChatGPT die locale im laufenden Betrieb ändert
Im normalen Web klickt der Nutzer selbst „EN / RU“ und Sie wissen genau, wann die Sprache zu wechseln ist. In der ChatGPT‑App kann das Modell theoretisch entscheiden, dass dem Nutzer eine andere Sprache lieber ist (oder der Nutzer ändert die Sprache der Oberfläche in den Einstellungen), und openai/locale ändert sich.
Wenn das SDK Ihnen ein reaktives Signal gibt (via Hook oder Event), sieht das Code‑Pattern so aus:
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
Hier wird loadMessages bei Änderung der locale erneut ausgeführt, und das gesamte UI rendert automatisch mit den neuen Übersetzungen. In den meisten realen Szenarien ist die Locale innerhalb einer Session stabil, aber ein korrektes reaktives Modell vorzusehen, ist dennoch sinnvoll.
9. Ein wenig zu komplexen Texten: Platzhalter und Pluralbildung
Die Reaktivität nach locale haben wir geklärt. Die nächste Frage liegt nahe: Was tun mit dynamischen Textteilen – Mengen, Namen usw.? In der Geschenk‑App könnte das etwas sein wie „Es wurden 3 Geschenke für Mascha gefunden“.
Der einfachste Weg, solche Phrasen zu handhaben, besteht darin, Platzhalter in t() zu unterstützen und Werte zur Laufzeit einzusetzen. Dazu modifizieren wir useT, sodass es als zweites Argument ein Werte‑Objekt annimmt:
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 };
}
Jetzt fügen wir eine Zeile in widget.json hinzu:
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
Und verwenden sie so:
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
Mit der Pluralbildung kann man unterschiedlich umgehen: Entweder mehrere Schlüssel (one, few, many) anlegen und sie manuell auswählen oder eine Bibliothek wie react-intl/i18next einsetzen, die vollständige Unterstützung für Pluralregeln bietet. Für ein Lern‑Widget ist die manuelle Auswahl nach Bereichen (zum Beispiel if count === 1, if count < 5 usw.) völlig akzeptabel.
10. Wo i18n in der Struktur der Next.js‑Vorlage des Apps SDK leben sollte
Aus Sicht von Next.js 16 und der offiziellen Apps‑SDK‑Vorlage ist Ihr Widget üblicherweise ein spezialisierter Einstiegspunkt unter app/ (zum Beispiel app/widget/page.tsx oder eine separate Komponente, die das Apps SDK innerhalb von ChatGPT rendert).
Typisches Pattern:
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
Die i18n‑Schicht lebt vollständig im Client‑Teil – alles, was wir oben geschrieben haben, sind Client Components. Wichtig: In der ChatGPT‑Umgebung wird ohnehin alles im Client innerhalb eines iframe gerendert, daher können die klassischen SSR‑i18n‑Patterns (lokalisierter HTML auf dem Server) vorerst ignoriert werden. Das vereinfacht vieles: Sie arbeiten wie mit einem gewöhnlichen SPA, nur dass Sie statt navigator.language openai/locale verwenden.
Wenn Sie Übersetzungen zwischen mehreren Widgets einer App teilen möchten (zum Beispiel Haupt‑Wizard und „kleines Inline‑Widget“), können Sie den I18nProvider in ein separates Modul auslagern und wiederverwenden.
11. Mini‑Tests für die Lokalisierung
Sobald es eine i18n‑Schicht im System gibt, sollte man sie separat testen – sonst wird jeder Tippfehler im Schlüssel zu einem „halb übersetzten UI“. Wenn wir schon eine Architektur gebaut haben, sollten wir sie auch prüfen.
Erstens lohnt es sich, einfache Unit‑Tests für loadMessages und useT zu schreiben (mit React Testing Library oder sogar ohne React – indem man einfach die Funktion t testet). Solche Tests fangen Tippfehler in Schlüsseln ab und helfen, wenn Sie oder der Übersetzer versehentlich einen benötigten Zweig im Wörterbuch löschen.
Zweitens ist es praktisch, einen Modus für den „lokalen Lauf“ des Widgets außerhalb von ChatGPT vorzusehen, in dem Sie die locale per Query‑Parameter oder Button im UI erzwingen können. Das ist für Sie und für QA hilfreich: Niemand muss den gesamten Dev Mode und ChatGPT starten, nur um zu sehen, wie die deutsche Übersetzung aussieht. Mit solchen Basistests und einem lokalen Durchlauf über verschiedene locale entwickeln Sie UI und Texte deutlich entspannter weiter und gehen anschließend zur Lokalisierung der Tool‑Beschreibungen über.
Wie das alles mit dem Verhalten des Modells zusammenhängt
Eine tiefere Lokalisierung der Tool‑Beschreibungen behandeln wir in der nächsten Vorlesung, aber schon jetzt ist die Verbindung wichtig: Widget und Tools sollten die Sprache des Nutzers sprechen. Sie bauen bereits ein UI, das sich an openai/locale anpasst. Der MCP‑Server wählt anhand desselben Signals den richtigen Katalog und die Texte. Folgerichtig werden sowohl die Beschreibung von suggest_gifts als auch die Felder recipient, budget dem Modell in der Sprache des Nutzers erklärt – das reduziert seltsame Tool‑Calls und fehlerhafte Argumente.
Die i18n‑Architektur des Widgets ist also nicht nur Kosmetik. Sie ist der erste Baustein eines Gesamtsystems, in dem UI‑Schicht, MCP‑Schicht und Modell denselben Locale‑Kontext verwenden.
12. Typische Fehler bei der Lokalisierung von Widgets
Fehler Nr. 1: Hartkodierte Strings direkt in JSX.
Sehr häufig: Ein Widget wurde als schneller Prototyp in einer Sprache begonnen, und plötzlich kommt „wir brauchen zusätzlich Englisch“. Das UI ist dann mit Strings in einer Sprache gespickt, und der Versuch, Englisch hinzuzufügen, wird zur globalen Suchen‑/Ersetzen‑Aktion. Je früher Sie Wörterbücher und die Funktion t() einführen, desto weniger Probleme gibt es später.
Fehler Nr. 2: if (locale === 'ru') an jeder Ecke.
Solch eine Bedingung wirkt manchmal wie eine „schnelle Lösung“, bricht aber sofort, sobald eine dritte Sprache oder Varianten wie ru-RU, ru, ru-UA auftauchen. Schreiben Sie lieber einmal loadMessages(locale) mit Normalisierung (locale.split('-')[0]) und denken dann nicht mehr daran, statt Checks über den ganzen Code zu verteilen.
Fehler Nr. 3: Vermischung von Geschäftslogik und Texten.
Manchmal bauen Entwickler komplexe Bedingungen in Komponenten, die gleichzeitig Geschäftsverzweigungen und die Textauswahl steuern. Etwa „wenn es keine Geschenke gibt, diesen Satz zeigen, und wenn das Budget klein ist – einen anderen“. Am Ende ist Copy schwer zu ändern, Logik zerfasert, und Übersetzungen kriechen in TypeScript. Viel besser ist es, wenn Komponenten nur den Schlüssel an die Wörterbücher übergeben (errors.no_gifts, errors.budget_too_low) und Texte separat editiert werden.
Fehler Nr. 4: Fehlendes Formatieren von Datumsangaben/Währungen nach Locale.
Einem Nutzer in Deutschland den Preis $1,234.56 statt 1.234,56 $ zu zeigen, ist kein Bug, sondern ein UX‑Anti‑Pattern. Nutzer empfinden das jedoch als „dieser Service ist nicht für mich gemacht“. Es ist leicht, Intl.NumberFormat und Intl.DateTimeFormat zu vergessen, wenn man an eine Region gewöhnt ist. Deshalb ist es sinnvoll, Formatter in einen Hook wie useFormatters() auszulagern und sie statt manueller String‑Konkatentation immer zu verwenden.
Fehler Nr. 5: Möglichen Wechsel der locale nicht berücksichtigen.
Manche Entwickler lesen die locale einmal beim Mounten und betrachten sie dann als Konstante. Meistens funktioniert das, aber wenn ChatGPT oder die Plattform die Locale doch ändert (z. B. weil der Nutzer die Oberfläche umstellt), bleibt Ihr Widget in der alten Sprache. Behandeln Sie locale besser als Teil des reaktiven Zustands und hängen Sie useMemo/useEffect daran.
Fehler Nr. 6: Unterschiedliche Wörterbuchstrukturen für verschiedene Sprachen.
Manchmal vertraut man die Übersetzung für eine Sprache einer Person an und für eine andere Sprache einer anderen – und am Ende driften widget.en.json und widget.ru.json in der Struktur auseinander. In der einen gibt es forms.budget.placeholder, in der anderen nur forms.budget.label. Zur Laufzeit führt das zu undefined und merkwürdigen Fehlern. Halten Sie immer eine „kanonische“ Datei (meist Englisch), von der andere Sprachen die Struktur erben. Für neue Wörterbücher können Sie sogar Skripte schreiben, die die Schlüssel auf Übereinstimmung prüfen.
Fehler Nr. 7: Der Versuch, sofort alles mit einem schweren i18n‑Framework zu lösen.
Beliebte Lösungen wie react-i18next oder next-intl sind mächtig und nützlich, aber für ein kleines ChatGPT‑Widget eventuell überdimensioniert. Oft ist es einfacher, mit einer leichten eigenen Schicht zu beginnen (I18nProvider, useT, Wörterbücher in JSON) und später – wenn die App wächst – auf eine vollwertige Bibliothek zu migrieren, falls Sie wirklich komplexe Pluralregeln, ICU‑Format u. Ä. benötigen.
GO TO FULL VERSION