CodeGym /コース /ChatGPT Apps /ウィジェットのローカリゼーション: Next + React(i18nアーキテクチャ)

ウィジェットのローカリゼーション: Next + React(i18nアーキテクチャ)

ChatGPT Apps
レベル 9 , レッスン 2
使用可能

1. なぜChatGPT Appのウィジェットに専用のi18nアーキテクチャが必要か

一般的な Next.js アプリでは、言語をルートに紐づけるために URL(/en/.../ru/...)やルーターに依存することがよくあります。ChatGPT のウィジェットでは少し事情が違います。UI はサンドボックス内の iframe で動作し、URL はあなたの管理下にありません。言語は ChatGPT から状態として渡ってきます(たとえば openai/localeuseOpenAiGlobal('locale') のようなフック経由)であり、アドレスバーからではありません。

結果として少し特殊な状況になります。Next.js の視点では、あなたのウィジェットは仮に 1 ページ /widget に見えますが、その中でプラットフォームが指示する任意の言語でレンダリングできなければなりません。言語の切り替えはナビゲーションではなく、状態で行う必要があります。これは「1つのUIで多言語辞書」というアーキテクチャに自然と導き、さらに「文字列をコードに持たせるのは行き止まり」であることを強調します。

加えて、同じ ChatGPT の会話内でも、あなたの App が異なる国のユーザー向けに起動されることがあります。「この App はロシア語だけ」と一度決めて終わり、とはいきません。ウィジェットはビジネスロジックを変えずに新しい locale に簡単に再初期化できる必要があります。そのために丁寧な i18n レイヤーが必要です。

2. 主な原則: コード内に生の文字列を置かない

UI ローカリゼーションの哲学を簡潔に言うと、React コンポーネントに必要なのは実テキストではなくキーである、ということです。

次のように書くのではなく:


// 悪い例: 文字列がコンポーネントにハードコードされている
<button>ギフトを選ぶ</button>

ウィジェットは次のように書くべきです:


// 良い例: コンポーネントはキーだけを知っている
<button>{t('buttons.pick_gift')}</button>

実際の文字列「ギフトを選ぶ」や「Pick a gift」は、ru.jsonen.json の辞書に保存します。

「単に iflocale === 'ru')でよくない?」という疑問もあるでしょう。

第一に、スケーラビリティです。3つ目の言語を追加した瞬間、if/else はスパゲッティになります。第二に、責務分離です。翻訳者やプロダクト担当はコードに触れずに JSON ファイルのテキストを変更でき、開発者は UI コピーを壊すリスクなくコンポーネントをリファクタできます。第三に、一貫性です。テキストの単一の「情報源」を持つことで、ボタンごとに表記ゆれ(「購入」と「支払い」など)が作者の気分次第で発生する状況を防げます。

ChatGPT App の世界ではこれは特に有用です。翻訳を LLM で生成してから辞書に追加したい場合もあります。すべてのテキストを JSON ファイルに集約しておくほうが、コンポーネントに散在させるよりもずっと扱いやすいのです。

3. GiftGenius ウィジェットの辞書を構成する

学習用アプリ GiftGenius(ギフト提案ウィジェット)を発展させましょう。最低でも ruen の 2 言語が必要です。次のような基本構成を作ります:

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

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."
  }
}

対応する locales/ru/widget.json:

{
  "title": "GiftGenius",
  "forms": {
    "recipient": {
      "label": "受取人",
      "placeholder": "誰へのプレゼントですか?"
    },
    "budget": {
      "label": "予算",
      "placeholder": "例:50"
    }
  },
  "buttons": {
    "pick_gift": "ギフトを探す",
    "try_again": "もう一度試す"
  },
  "errors": {
    "no_gifts": "条件に合うギフトが見つかりませんでした."
  }
}

両言語でキー構造が同一であることに注意してください。これは非常に重要です。コンポーネントは文字列ではなくキーに依存します。もしある言語で errors.no_gifts を追加し忘れると、半分だけ翻訳された UI ではなく、わかりやすいエラーとして発見できます。

実プロジェクトでは、辞書を領域ごとに分割するのが合理的です(widgetcheckouterrors など)。学習用アプリでは複雑にしすぎないよう、言語ごとに 1 ファイルで十分です。

4. Apps SDK のウィジェットで locale をどこから取得するか

従来のブラウザアプリなら navigator.language を見に行くでしょう。ChatGPT ウィジェットではそれも可能ですが、推奨はしません。ChatGPT はすでにユーザーの優先ロケールを判定し、Apps SDK のコンテキストとして渡しているためです。これは window.openai にある locale フィールドかもしれず、直接読むことも、useOpenAiGlobal('locale') のような便利なフック経由でも読めます。

Apps SDK スターターでは、ChatGPT からのグローバルデータにアクセスできるウィジェットのルートコンポーネントがあるのが一般的です。たとえば次のような形です:

"use client";

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

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

上のサンプルは説明用であり、正確な API は SDK のバージョンによって異なります。ただし大筋は同じです。locale はブラウザではなく ChatGPT から渡ってくる外部の真実、ということです。

リージョン(userLocation)も _meta["openai/userLocation"] 経由で渡されます。これは価格フォーマットや通貨を扱うときに役立ちます。テキストに関しては locale だけで十分で、一般に BCP‑47 形式(enen-USru-RU など)で届きます。

5. 最小の i18n レイヤーを書く: コンテキスト + フック useT

ウィジェットを自給自足にし、react-i18next の教科書にならないよう、軽量な自作 i18n レイヤーを実装します。小さな ChatGPT ウィジェットにはこれで十分で、原則は有名ライブラリと同じです。

まず、型を定義し 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);

次に、locale と辞書を受け取るプロバイダーを作ります:

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

肝はキーから文字列を取り出すフック useT です:

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

forms.recipient.label のようなネストしたキーに対応し、翻訳が見つからない場合はキー自体を返します。空文字を黙って出すより有用です。

6. i18n プロバイダーをウィジェットのルートコンポーネントに組み込む

先ほどは useOpenAiGlobal から locale を読み取るだけの GiftWidgetRoot を見ました。ここに I18nProvider を組み込み、辞書の読み込みを追加します。もともと次のようだったとします:

"use client";

export function GiftWidgetRoot() {
  return (
    <div>
      <h1>GiftGenius</h1>
      {/* フォームと結果 */}
    </div>
  );
}

辞書の読み込みとプロバイダーを追加します。簡単のために require/importlocale に応じて同期的に使いますが、辞書が大きい場合は Next.js 16 で(dynamic import による)非同期インポートも可能です。

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

これで GiftWidget は言語のことを考える必要がなくなり、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>
      {/* 残りのUI */}
    </div>
  );
}

明日 ChatGPT が locale = "de-DE" でウィジェットを作ったとしても、locales/de/widget.json を追加し、loadMessages に 1 行書き足すだけで済み、他のコードに触れる必要はありません。これこそがすべての狙いです。

7. ローカライズされる書式: 数値・日付・通貨

テキストを辞書に移し、ウィジェットを I18nProvider で包みました。しかし UX の半分はテキスト以外です。米国のユーザーは 12/31/2025 を、ドイツのユーザーは 31.12.2025 を期待します。数字や通貨も同様です。ロシアのユーザーに「1,234.56 USD」と見せるのは、「あなたの“スマート”アシスタントは細部に注意していない」と示すのに格好の方法になってしまいます。

幸い、ブラウザ(および ChatGPT のサンドボックス)では標準の Intl API が使えます。i18n.tsx に現在の 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 };
}

たとえば、MCP サーバーから 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>
  );
}

さらに「賢い」フォーマット(たとえば userLocation を基に通貨を選ぶ)にしたい場合は、locale とリージョンを組み合わせればよいでしょう。これは、MCP Gateway で議論したアーキテクチャの延長です。locale はテキストの言語に、userLocation はビジネスルールや通貨に影響します。

8. 言語変更への反応: ChatGPT が途中で locale を変えたら?

一般的な Web では、ユーザーが自分で「EN / RU」を押すため、切り替えのタイミングは明確です。ChatGPT App では、モデルが「ユーザーは別の言語のほうが便利」と判断する(あるいはユーザーが設定で UI 言語を切り替える)可能性があり、openai/locale が変化することがあります。

SDK がフックやイベントでリアクティブなシグナルを提供してくれる場合、コードパターンは次のようになります:

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

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

ここでは loadMessageslocale の変更時に再実行され、UI 全体が新しい翻訳で自動的に再レンダリングされます。多くの実運用シナリオではセッション中のロケールは安定していますが、正しいリアクティブモデルを入れておく価値は十分にあります。

9. 複雑な文字列について少し: プレースホルダーと複数形

locale のリアクティブ対応ができたら、次の自然な疑問は「数量や名前など、動的な部分を含むテキストはどうするか」です。ギフトアプリでは「マーシャのためのギフトを3件見つけました」のような表現がありえます。

最も簡単なアプローチは、t() でプレースホルダーをサポートし、値をその場で差し込むことです。そのために useT を修正し、2 番目の引数で値のオブジェクトを受け取れるようにします:

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

次に widget.json に文字列を追加します:

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

そしてこう使います:

const { t } = useT();

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

複数形はさまざまな方法で処理できます。キーを複数用意(onefewmany など)して手動で選ぶか、react-intl/i18next のように plural rules をフルサポートするライブラリを使うかです。学習用ウィジェットでは、範囲に基づく手動選択(たとえば if count === 1if count < 5 など)でも十分許容できます。

10. Next.js の Apps SDK テンプレートで i18n をどこに置くか

Next.js 16 と公式の Apps SDK テンプレートの観点では、ウィジェットは通常 app/ 内の特化したエントリポイント(たとえば app/widget/page.tsx、あるいは Apps SDK が ChatGPT 内でレンダリングする独立コンポーネント)です。

典型的なパターン:

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

import { GiftWidgetRoot } from "./GiftWidgetRoot";

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

i18n レイヤーは完全にクライアント側に存在します。ここまで書いたものはすべてクライアントコンポーネントです。ChatGPT 環境ではそもそも iframe 内のクライアントレンダリングになるため、サーバーでローカライズ済み HTML を返すような古典的 SSR i18n パターンはひとまず忘れて構いません。大幅に楽になります。つまり、通常の SPA と同じように扱いつつ、navigator.language の代わりに openai/locale を使うだけです。

同じ App 内の複数ウィジェット(たとえばメインのウィザードと「小さなインラインウィジェット」)間で翻訳を共有したい場合は、I18nProvider を別モジュールに切り出して再利用できます。

11. ローカリゼーションのミニテスト

i18n レイヤーを導入したらすぐに、それを個別にテストすべきです。そうしないと、キーのタイプミスが「半分だけ翻訳された UI」を生みがちです。せっかくアーキテクチャを整えたのですから、検証しない手はありません。

第一に、loadMessagesuseT に対して簡単なユニットテストを書くのは有効です(React Testing Library を使っても、React 抜きで t 関数だけをテストしても構いません)。これらのテストはキーのタイプミスを捕捉し、翻訳者やあなたが誤って辞書の必要な枝を削除した場合にも助けになります。

第二に、ChatGPT 外での「ローカル実行モード」を用意して、locale をクエリパラメータや UI のボタンで強制指定できるようにしておくと便利です。これはあなたにも QA にも有益です。ドイツ語の見え方を確認するためだけに Dev Mode と ChatGPT 全体を起動する必要はありません。こうした基本テストと複数 locale でのローカル実行があれば、UI とテキストの双方をより安心して拡張でき、さらに tools の説明のローカライズへと進めます。

これがモデルの振る舞いとどう関係するか

ツールの descriptions の本格的なローカライズは次回扱いますが、ここでもう重要な結論があります。ウィジェットとツールはユーザーと同じ言語で話すべきです。すでに UI は openai/locale に合わせて切り替わるよう設計しています。MCP サーバーも同じシグナルで正しいカタログやテキストを選びます。論理的に、suggest_gifts の説明や recipientbudget といったフィールドも、ユーザーの言語でモデルに説明すべきです。これにより不自然な tool‑call や不正な引数が減ります。

つまり、ウィジェットの i18n アーキテクチャは単なる見た目の問題ではありません。UI 層、MCP 層、モデルが同じロケール文脈を共有するための最初のレンガなのです。

12. ウィジェットのローカリゼーションでよくあるミス

ミス №1: JSX に生の文字列をハードコードする。
よくある話です。1 言語だけのプロトタイプとして素早く作り始め、突然「英語も必要」となったケース。結果的に UI がロシア語の文字列だらけになり、英語追加の試みはプロジェクト全体の検索置換に化けます。t() 関数と辞書を早めに導入すれば、その後の問題は大幅に減ります。

ミス №2: あちこちで iflocale === 'ru')。
この条件は「手っ取り早い解」に見えることがありますが、3つ目の言語や ru-RUruru-UA のようなバリエーションが現れた瞬間に破綻します。locale.split('-')[0] のように正規化した loadMessages(locale) を 1 か所に書いて、全体で使い回すほうがはるかに健全です。

ミス №3: ビジネスロジックとテキストの混在。
コンポーネントの中で、ビジネス分岐とテキスト選択を同時に処理する複雑な条件を書いてしまうことがあります。たとえば「ギフトがないならこのフレーズ、予算が小さいなら別のフレーズ」という具合です。結果としてコピーの変更が難しくなり、ロジックは拡散し、翻訳が TypeScript に入り込んでしまいます。コンポーネントはキー(errors.no_giftserrors.budget_too_low など)だけを辞書に渡し、テキストは別に編集できるようにするのが最適です。

ミス №4: ロケールに応じた日付/通貨フォーマットをしない。
ドイツのユーザーに $1,234.56 と表示すべきところを 1.234,56 $ とすべき、のような違いはバグというより UX アンチパターンですが、ユーザーは「自分向けに作られていない」と感じます。Intl.NumberFormatIntl.DateTimeFormat を、1 つの地域に慣れたがゆえに見落としがちです。そのため、useFormatters() のようなフックにフォーマッターをまとめ、手動の文字列連結を避けるのが有効です。

ミス №5: locale 変更の可能性を考慮しない。
マウント時に一度だけ locale を読み取り、その後は定数扱いしてしまうケースです。多くの場合は動きますが、ChatGPT やプラットフォームがロケールを変更した(たとえばユーザーが UI 言語を切り替えた)場合、ウィジェットは古い言語のままになります。locale をリアクティブな状態の一部として扱い、useMemo/useEffect に結びつけるのが正解です。

ミス №6: 言語ごとに辞書構造が異なる。
言語ごとに別の担当者が翻訳し、結果として widget.en.jsonwidget.ru.json の構造がズレることがあります。一方には forms.budget.placeholder があり、他方には forms.budget.label しかない、といった具合です。実行時には undefined と不思議なエラーを招きます。常に 1 つの「正準」ファイル(通常は英語)を持ち、他言語はその構造に従うようにしてください。キー対応を検査するスクリプトを用意するのも良いでしょう。

ミス №7: いきなり重い i18n フレームワークで全部解決しようとする。
react-i18nextnext-intl のような人気の解決策は強力で有用ですが、小さな ChatGPT ウィジェットには過剰なこともあります。まずは軽量な自作レイヤー(I18nProvideruseT、JSON 辞書)から始め、アプリが成長して本当に必要になったときに、複雑な複数形や ICU フォーマットを備えた本格ライブラリへ移行するのが賢明です。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION