CodeGym /Courses /ChatGPT Apps /Widget localization: Next + React (i18n architecture)

Widget localization: Next + React (i18n architecture)

ChatGPT Apps
Level 9 , Lesson 2
Available

1. Why a widget needs a dedicated i18n architecture in the ChatGPT App

In a typical Next.js app, you often rely on the URL (/en/..., /ru/...) or the router to bind a language to a route. In a ChatGPT widget it’s more fun: your UI lives inside an iframe sandbox, and the URL isn’t under your control. The language comes in as state from ChatGPT, for example via openai/locale or a hook like useOpenAiGlobal('locale'), not from the address bar.

That creates an unusual situation. From Next.js’s point of view, your widget is effectively a single page /widget, but inside it must be able to render itself in whatever language the platform specifies. You switch language not through navigation but through state. This naturally pushes you toward an architecture of “one UI, many dictionaries,” and it underscores again: keeping strings in code is a dead end.

Additionally, within the same ChatGPT conversation your App may be launched for users from different countries. You can’t “decide once that the App is Russian‑language” and forget about it. The widget should easily reinitialize for a new locale without changing the business logic—that’s exactly why you need a careful i18n layer.

2. Core principle: there should be no strings in code

If we put the UI‑localization philosophy briefly, it’s this: React components don’t need real texts; they need keys.

Instead of:


// BAD: string is hardcoded in the component
<button>Pick a gift</button>

the widget should look like:


// GOOD: the component only knows the key
<button>{t('buttons.pick_gift')}</button>

And the real strings “Podobrat’ podarok” and “Pick a gift” are stored in the dictionaries ru.json and en.json.

Why all this complexity if you could just do if (locale === 'ru')?

First, scalability. As soon as you need a third language, if/else turns into a mess. Second, separation of concerns. A translator or PM can change texts in JSON files without touching code, and a developer can refactor components without risking breaking half of the UI copy. Third, consistency: a single source of truth for texts helps avoid situations where one button says “Buy” and another—“Pay” simply because the authors of the components named it based on their mood.

In the ChatGPT App world this is especially useful: sometimes you’ll want to generate translations via an LLM and then add them to dictionaries. Keeping all texts in JSON files is much more convenient than scattering them across components.

3. Structuring dictionaries for the GiftGenius widget

Let’s continue developing our training app GiftGenius—a gift‑picking widget. We already need at least two languages: ru and en. Let’s create a basic structure:

/app
  /widget
    GiftWidget.tsx
/locales
  /en
    widget.json
  /ru
    widget.json

The simplest contents of 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."
  }
}

And the corresponding locales/ru/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."
  }
}

Note that the key structure is identical for both languages. This is critical: components rely on keys, not on specific strings. If you forget to add errors.no_gifts in one language, you’ll get a clear error rather than a half‑translated UI.

In a real project, it makes sense to split dictionaries by domain: widget, checkout, errors, etc. In a training app, one file per language is enough to keep things simple.

4. Where to get locale in an Apps SDK widget

In a classic browser app you’d poke at navigator.language. In a ChatGPT widget you can do that, but you shouldn’t: ChatGPT has already determined the user’s preferred locale and passes it into the Apps SDK context. It could be a locale field on window.openai, which you can read directly or via a convenient hook like useOpenAiGlobal('locale').

Typically for Apps SDK starters you have a root widget component where global data from ChatGPT is available. Roughly:

"use client";

import { useOpenAiGlobal } from "openai-apps-sdk/react";

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  // ...
}

The example above is illustrative; the exact API depends on the SDK version, but the general idea holds: locale is external truth coming from ChatGPT, not from the user’s browser.

The region (userLocation) is also passed via _meta["openai/userLocation"]. We’ll need it a bit later when formatting prices and taking currency into account. For texts, locale is enough—it typically comes in BCP‑47 format (en, en-US, ru-RU, etc.).

5. Writing a minimal i18n layer: context + useT hook

To keep the widget self‑contained and not turn it into a tutorial on react-i18next, we’ll implement a lightweight custom i18n layer. For a small ChatGPT widget this is more than enough, and the principles match popular libraries.

First, define types and create a context 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);

Now make a provider that receives locale and the dictionary:

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>
  );
}

The interesting part is the useT hook that will fetch strings by key:

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 };
}

We support nested keys like forms.recipient.label and, if a translation is missing, return the key itself—this is more helpful than silently showing nothing.

6. Integrating the i18n provider into the widget’s root component

Earlier we saw GiftWidgetRoot simply reading locale from useOpenAiGlobal. Now we’ll use I18nProvider in this root component and add dictionary loading. Suppose it previously looked something like this:

"use client";

export function GiftWidgetRoot() {
  return (
    <div>
      <h1>GiftGenius</h1>
      {/* forms and results */}
    </div>
  );
}

Let’s add dictionary loading and the provider. For simplicity, we’ll use synchronous require/import based on locale, but in Next.js 16 you can also use async import (via dynamic import) if dictionaries are large.

"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>
  );
}

The GiftWidget component now doesn’t think about languages at all; it only knows there’s a t function:

"use client";

import { useT } from "./i18n";

export function GiftWidget() {
  const { t } = useT();

  return (
    <div>
      <h1>{t("title")}</h1>
      <label>{t("forms.recipient.label")}</label>
      {/* rest of the UI */}
    </div>
  );
}

If tomorrow ChatGPT creates a widget with locale = "de-DE", you’ll be able to add locales/de/widget.json and a single line in loadMessages without touching the rest of the code. That’s the point of all this.

7. Localizable formats: numbers, dates, currencies

We’ve already moved texts into dictionaries and wrapped the widget in I18nProvider. But texts are only half of the UX: a user in the US expects to see 12/31/2025, while a user in Germany—31.12.2025. Same with numbers and currencies. Showing a user in Russia the price “1,234.56 USD” is a good way to signal that your “smart” assistant isn’t very attentive.

Fortunately, the standard Intl API is available in the browser (and in the ChatGPT sandbox). Let’s add a couple utilities to i18n.tsx that use the current 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 };
}

Now in a component where we show the budget or gift prices (let’s say we already receive them from an MCP server with a specified 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>
  );
}

If you want to make formatting even “smarter” (for example, choose the currency based on userLocation), you can combine locale and region. Architecturally this continues the approach you’ve already discussed for the MCP Gateway: locale affects language, userLocation—business rules and currency.

8. Reacting to language changes: what if ChatGPT changes locale on the fly

On the regular web, the user clicks “EN / RU” and you know exactly when to switch. In a ChatGPT App, the model can in theory decide the user is better served in another language (or the user switches the interface language in settings), and openai/locale changes.

If the SDK gives you a reactive signal (via a hook or event), the code pattern will be:

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  const messages = useMemo(() => loadMessages(locale), [locale]);

  return (
    <I18nProvider locale={locale} messages={messages}>
      <GiftWidget />
    </I18nProvider>
  );
}

Here loadMessages will rerun when locale changes, and the entire UI will automatically re‑render with new translations. In most real scenarios, the locale is stable within a session, but it’s still useful to set up the correct reactive model.

9. A bit about complex strings: placeholders and pluralization

We’ve covered reactivity by locale. The next natural question is: what to do with dynamic text parts—quantities, names, etc.? In a gift app, it might be something like “Found 3 gifts for Masha.”

The simplest way to handle such phrases is to support placeholders in t() and substitute values on the fly. To do this, we’ll modify useT to accept an object of values as the second argument:

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 };
}

Now add a string to widget.json:

"results": {
  "summary": "Found {{count}} gifts for {{name}}"
}

And use it:

const { t } = useT();

<p>{t("results.summary", { count, name: recipientName })}</p>

Pluralization can be handled in different ways: either create several keys (one, few, many) and select them manually, or plug in a library like react-intl/i18next, which have full plural rules support. For a training widget, a manual selection by ranges (for example, if count === 1, if count < 5, etc.) is perfectly acceptable.

10. Where to place i18n in the Next.js Apps SDK template

From the perspective of Next.js 16 and the official Apps SDK template, your widget is usually a specialized entrypoint under app/ (for example, app/widget/page.tsx or a separate component that the Apps SDK renders inside ChatGPT).

Typical pattern:

// app/widget/page.tsx
"use client";

import { GiftWidgetRoot } from "./GiftWidgetRoot";

export default function WidgetPage() {
  return <GiftWidgetRoot />;
}

The i18n layer lives entirely on the client—everything we wrote above is client components. Importantly, in the ChatGPT environment everything is rendered on the client inside an iframe anyway, so you can ignore classic SSR‑i18n patterns (localized HTML on the server) for now. That greatly simplifies life: you work like with a regular SPA, except instead of navigator.language you use openai/locale.

If you need to share translations across multiple widgets of one App (for example, the main wizard and a “small inline widget”), you can extract I18nProvider into a separate module and reuse it.

11. Mini testing of localization

Once an i18n layer appears in the system, you should start testing it separately—otherwise any typo in a key turns into a “half‑translated UI.” Since we designed the architecture, it would be a shame not to verify it.

First, it makes sense to write simple unit tests for loadMessages and useT (using React Testing Library or even without React—just testing the t function). Such tests catch key typos and help if you or a translator accidentally delete a needed branch of the dictionary.

Second, it’s convenient to provide a “local run” mode for the widget outside ChatGPT where you can forcibly specify locale via a query parameter or a UI button. This is useful for you and for QA: no one should be required to spin up the whole Dev Mode and ChatGPT just to see how the German translation looks. With these basic tests and local runs across different locale values, you’ll be much more confident as you evolve the UI and texts and then move on to localizing tool descriptions.

How all this relates to model behavior

We’ll go deep into localizing tool descriptions in the next lecture, but it’s already important to see the linkage: the widget and tools should speak the user’s language. You’re already building a UI that adapts to openai/locale. The MCP server uses the same signal to choose the right catalog and texts. It’s logical that the description of suggest_gifts and the fields recipient, budget will be explained to the model in the user’s language—this will reduce odd tool calls and incorrect arguments.

In other words, the widget’s i18n architecture isn’t just cosmetic. It’s the first brick in an overall system where the UI layer, the MCP layer, and the model use the same locale context.

12. Common mistakes when localizing widgets

Mistake #1: hardcoded strings directly in JSX.
A very common story: the widget started as a quick prototype in one language, and then suddenly “we also need English.” The result is a UI riddled with Russian strings, and adding English turns into a global search‑and‑replace across the project. The earlier you establish dictionaries and a t() function, the fewer problems you’ll face later.

Mistake #2: if (locale === 'ru') everywhere.
This can look like a “quick fix,” but it instantly breaks as soon as a third language appears or variants like ru-RU, ru, ru-UA. It’s better to write loadMessages(locale) once with normalization (locale.split('-')[0]) and forget about it, than to smear checks across the entire codebase.

Mistake #3: mixing business logic and texts.
Sometimes developers introduce complex conditions inside components that simultaneously handle business branching and text selection. For example, “if there are no gifts, show this phrase, and if the budget is small—another.” As a result, copy is hard to change, logic spreads, and translations creep into TypeScript. It’s much better when components hand off just a key to dictionaries (errors.no_gifts, errors.budget_too_low), and texts are edited separately.

Mistake #4: not formatting dates/currencies by locale.
Showing a user in Germany the price $1,234.56 instead of 1.234,56 $ isn’t a bug; it’s a UX anti‑pattern. But users perceive it as “this service isn’t made for me.” It’s very easy to forget about Intl.NumberFormat and Intl.DateTimeFormat if you’re used to one region. That’s why it’s helpful to extract formatters into a hook like useFormatters() and always use them instead of manual string concatenation.

Mistake #5: ignoring possible locale changes.
Some developers read locale once on mount and then treat it as a constant. In most cases that will work, but if ChatGPT or the platform does change the locale (for example, the user switches the interface language), your widget will stay in the old language. It’s better to treat locale as reactive state and tie useMemo/useEffect to it.

Mistake #6: keeping different dictionary structures for different languages.
Sometimes one person handles one language and another person handles another, and as a result widget.en.json and widget.ru.json diverge in structure. One has forms.budget.placeholder, the other only forms.budget.label. At runtime this turns into undefined and odd errors. Always keep one “canonical” file (usually English) from which other languages inherit the structure. You can even write scripts that check key parity when generating new dictionaries.

Mistake #7: trying to solve everything at once with a heavy i18n framework.
Popular solutions like react-i18next or next-intl are powerful and useful, but for a small ChatGPT widget they can be overkill. It’s often simpler to start with a lightweight custom layer (I18nProvider, useT, JSON dictionaries), and later, as the application grows, migrate to a full library if you truly need complex pluralization, ICU format, etc.

1
Task
ChatGPT Apps, level 9, lesson 2
Locked
DebugLocale — diagnostic tool for locale tracing
DebugLocale — diagnostic tool for locale tracing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION