Skip to content

Internationalization

Introduction

This guide covers ArenaPro's internationalization (i18n) setup for both client and server: where to initialize, type safety (t-keys autocompletion), how to use t() in code, how to add new keys/languages, and how to switch language at runtime.

Pain points of the traditional workflow

  • Hard-coded strings scattered across the codebase are hard to maintain.
  • Key names get copied manually and easily go out of sync across languages.
  • Non-developers cannot safely contribute to localization.

ArenaPro's i18n practice treats text resources as first-class assets and gives you IDE autocompletion and type checks:

  1. Type-safe key suggestions to avoid typos and missing keys.
  2. Missing translations are revealed during development, not after release.
  3. Unified file organization so adding keys/languages follows a clear process.

Directory & Files

  • Shared initialization and exports (client/server shared): i18n/index.ts
  • i18n type augmentation (ensure t-keys type safety): i18n/types/i18n.d.ts
  • Translation resources (JSON):
    • Simplified Chinese: i18n/res/zh-CN/translation.json
    • English: i18n/res/en/translation.json

We use res/zh-CN/translation.json as the single source of truth for key structure to guarantee type safety and editor autocompletion.

Initialization & How it Works

In i18n/index.ts:

  • Initialize i18next.
  • lng is auto-detected from navigator.language on the client (fallback to zh-CN on server).
  • fallbackLng is zh-CN.
  • Default namespace (defaultNS) is translation.
  • Resources are imported inline:
    • en@root/i18n/res/en/translation.json
    • zh-CN@root/i18n/res/zh-CN/translation.json
  • debug: false by default. Set to true during development to surface missing keys.

Note: The @root/* alias comes from client/tsconfig.json paths.

Exports

  • default export i18n: use i18n.t('key', options?) directly.

Type Safety & Autocompletion (module augmentation)

Through i18n/types/i18n.d.ts, we augment i18next types using the structure of zh-CN/translation.json as the type source. When you call i18n.t('...'), you get autocompletion and compile-time checks.

Therefore, when adding/editing keys, first update i18n/res/zh-CN/translation.json, then sync to other language files to keep type safety and consistency.

How to Use

  1. Basic usage:
ts
import i18n from "@root/i18n";

// simplest usage
const text = i18n.t("welcome_game");

// interpolation example (translation.json: "navigator.language": "Your browser language is: {{language}}")
const langText = i18n.t("navigator.language", { language: navigator.language });
  1. In UI code:
ts
import i18n from "@root/i18n";

const text = UiText.create();
text.parent = ui;
text.textContent = i18n.t("welcome_game");

Server Guide

Use the shared @root/i18n as well. Since there is no navigator.language on the server, pass the player's language explicitly when calling:

ts
import i18n from "@root/i18n";

// Shows the Chinese welcome text
console.log("(server):", i18n.t("welcome_game", { lng: "zh-CN" }));

// Shows the English welcome text
console.log("(server):", i18n.t("welcome_ap", { lng: "en" }));

Quick Reference: options for t()

Full docs: https://www.i18next.com/translation-function/essentials

ts
import i18n from "@root/i18n";

// 1) force language (server or strict cases)
i18n.t("welcome_game", { lng: "en" });

// 2) namespace
i18n.t("title", { ns: "ui" });

// 3) interpolation
// translation.json: { "hello": "Hello, {{name}}" }
i18n.t("hello", { name: "Alice" });

// 4) plural
// translation.json: { "item": "{{count}} item", "item_one": "{{count}} item", "item_other": "{{count}} items" }
i18n.t("item", { count: 1 });
i18n.t("item", { count: 5 });

// 5) context
// translation.json: { "actor": "Actor", "actor_male": "Actor (male)", "actor_female": "Actor (female)" }
i18n.t("actor", { context: "male" });
i18n.t("actor", { context: "female" });

// 6) default value
i18n.t("not.exist.key", { defaultValue: "Fallback text" });

// 7) return objects
// i18n.t('group', { returnObjects: true })

// 8) join arrays
// i18n.t('list', { joinArrays: ', ' })

Tips:

  • Client usually does not pass lng (initialized from navigator.language).
  • Server should pass lng: i18n.t(key, { lng: playerLang }).

Runtime Language Switching

ts
import i18n from "@root/i18n";

async function switchToEnglish() {
  await i18n.changeLanguage("en");
}

async function switchToChinese() {
  await i18n.changeLanguage("zh-CN");
}

After switching, calls to i18n.t() will immediately reflect the new language. If your UI framework is not reactive, manually trigger a refresh.

Maintenance & Expansion

Add or maintain translation keys

Use zh-CN as the baseline language:

  1. Update i18n/res/zh-CN/translation.json.
  2. Sync the same keys to i18n/res/en/translation.json (and other languages).
  3. Use i18n.t("your.new.key") in code.

Example:

json
// zh-CN/translation.json
{
  "common": {
    "ok": "确定",
    "cancel": "取消"
  }
}
json
// en/translation.json
{
  "common": {
    "ok": "OK",
    "cancel": "Cancel"
  }
}

Usage:

ts
import { t } from "@root/i18n";

const ok = t("common.ok");
const cancel = t("common.cancel");

Add a new language

  1. Create a directory and translation.json under i18n/res/, e.g. i18n/res/ja/translation.json.
  2. In i18n/index.ts:
ts
import ja_Translation from "./res/ja/translation.json";

await i18n.init({
  resources: {
    en: { translation: en_Translation },
    "zh-CN": { translation: zhCN_Translation },
    ja: { translation: ja_Translation },
  }
});
  1. At runtime, switch with i18n.changeLanguage("ja").

Advanced: Multiple Namespaces

When your project grows, stuffing everything into one translation.json becomes hard to maintain. Split by domain (e.g., common, ui, gameplay).

Directory organization (example)

i18n/res/
  en/
    common.json
    ui.json
    gameplay.json
  zh-CN/
    common.json
    ui.json
    gameplay.json

i18n initialization (i18n/index.ts)

ts
import i18next from "i18next";
import en_common from "@root/i18n/res/en/common.json";
import en_ui from "@root/i18n/res/en/ui.json";
import en_gameplay from "@root/i18n/res/en/gameplay.json";
import zh_common from "@root/i18n/res/zh-CN/common.json";
import zh_ui from "@root/i18n/res/zh-CN/ui.json";
import zh_gameplay from "@root/i18n/res/zh-CN/gameplay.json";

await i18next.init({
  lng: navigator.language,
  fallbackLng: "zh-CN",
  ns: ["common", "ui", "gameplay"],
  defaultNS: "common",
  resources: {
    en: { common: en_common, ui: en_ui, gameplay: en_gameplay },
    "zh-CN": { common: zh_common, ui: zh_ui, gameplay: zh_gameplay },
  },
  debug: true,
});

export default i18next;

Use multiple namespaces in code

Way 1: Pass ns when calling.

ts
import i18n from "@root/i18n";

const title = i18n.t("title", { ns: "ui" });
const start = i18n.t("button.start", { ns: "ui" });

Way 2: Get a fixed t for a specific namespace (recommended when used heavily within one module).

ts
import i18n from "@root/i18n";

const tUI = i18n.getFixedT(null, "ui");
const title = tUI("title");
const start = tUI("button.start");

Types: keep type safety with multiple namespaces

If you provide key autocompletion via module augmentation in i18n/types/i18n.d.ts, reshape the resources type accordingly:

ts
// i18n/types/i18n.d.ts
import "i18next";

declare module "i18next" {
  interface CustomTypeOptions {
    defaultNS: "common";
    // Note: use zh-CN files as the type source of truth
    resources: {
      common: typeof import("@root/i18n/res/zh-CN/common.json");
      ui: typeof import("@root/i18n/res/zh-CN/ui.json");
      gameplay: typeof import("@root/i18n/res/zh-CN/gameplay.json");
    };
  }
}

Tips:

  • Add new keys to zh-CN first, then mirror to others.
  • Within a module, prefer a fixed t (e.g., const tUI = getFixedT(..., "ui")).
  • Don't over-split namespaces; keep them domain-oriented.

FAQ

  • How to surface missing keys during development?

    • Set debug: true in i18n/index.ts (turn it off before release).
  • Will en-US match en?

    • Yes. If not matched, it falls back according to fallbackLng: "zh-CN".
  • t() returns the key itself—what now?

    • First check that zh-CN/translation.json includes the key (the type baseline).
    • Then verify other languages mirror the same key.
    • Finally confirm namespace and spelling (default is translation).

Best Practices

  • Maintain keys in zh-CN first, then sync others to keep types and content consistent.
  • Group keys by module, e.g., home.title, home.button.start.
  • Keep keys stable—avoid frequent renaming to reduce translation and maintenance cost.
  • Set debug to false before release to avoid noisy logs.

Why client and server usage differs

They operate in different runtime contexts:

  • Single-player vs multi-player

    • Client is a single user session with one current language (from navigator.language).
    • Server serves many players/sessions; each may have a different language—no global language.
  • Language source differs

    • Client initializes based on browser language and exports a bound t.
    • Server must read/report the player's language, then call i18n.t(key, { lng: playerLang }).
  • API summary (single shared module @root/i18n)

    • Client: i18n.t(key) directly.
    • Server: i18n.t(key, { lng }) with explicit language.

"Our game's comment section is blowing up!" came the urgent message in the creative team's QQ group.

You open the player feedback panel to see a sea of red alerts:

  • Japanese player Yamamoto: "中国語が読めない!" (Can't read Chinese!)
  • British player Tom: "Why is there no English version? I'm crying!"
  • French player Luc: "Où est la version française?" (Where is the French version?)
  • German player Hans: "Kann man die Sprache ändern?" (Can the language be changed?)

As the creator, you know you must immediately solve this language barrier problem! This is where i18next comes to the rescue! It's a powerful internationalization framework that can help you easily implement multilingual support.

Installation

bash
npm install i18next

Info

It is recommended to implement localized configurations in client-side scripts rather than directly integrating them into server-side scripts. This architecture design can better meet the personalized needs of different users, while following the best practices for front-end processing of user specific configurations.

Basic Configuration

typescript
// i18n.ts
import i18n from "i18next";

// Initialization configuration
i18n.init({
  lng: "en", // Default language
  fallbackLng: "en", // Fallback language
  debug: false, // Should be false in production
  resources: {
    en: {
      translation: {
        // English translations
        welcome: "Welcome!",
        greeting: "Hello, {{name}}!",
        buttons: {
          submit: "Submit",
          cancel: "Cancel",
        },
      },
    },
    zh: {
      translation: {
        // Chinese translations
        welcome: "欢迎!",
        greeting: "你好,{{name}}!",
        buttons: {
          submit: "提交",
          cancel: "取消",
        },
      },
    },
  },
});

export default i18n;

Core API Usage

1. Basic Translation

typescript
import i18n from "./i18n";

// Simple translation
console.log(i18n.t("welcome")); // Output: "Welcome!" or "欢迎!"

// With interpolation
console.log(i18n.t("greeting", { name: "Alice" })); // "Hello, Alice!" or "你好,Alice!"

// Nested keys
console.log(i18n.t("buttons.submit")); // "Submit" or "提交"

2. Language Switching

typescript
// Get current language
console.log(i18n.language); // Output current language code

// Change language
i18n.changeLanguage("zh").then(() => {
  console.log("Language switched to Chinese");
});

// Listen for language changes
i18n.on("languageChanged", (lng) => {
  console.log(`Language changed to: ${lng}`);
});

3. Pluralization

typescript
// Configure plural rules
i18n.init({
  // ...other configurations
  resources: {
    en: {
      translation: {
        itemCount: "{{count}} item",
        itemCount_plural: "{{count}} items",
      },
    },
    zh: {
      translation: {
        itemCount: "{{count}} 个项目",
      },
    },
  },
});

// Usage
console.log(i18n.t("itemCount", { count: 1 })); // "1 item" or "1 个项目"
console.log(i18n.t("itemCount", { count: 5 })); // "5 items" or "5 个项目"

Multilingual Configuration Files

MyGame/
├── i18n/
│   ├── config.ts                # i18n initialization config file
│   └── locales/                 # Multilingual resources directory
│       ├── en/                  # English language pack
│       │   ├── common.json      # Common translations
│       │   └── home.json        # Homepage-related translations
│       └── zh/                  # Chinese language pack
│           ├── common.json      # Common translations
│           └── home.json        # Homepage-related translations

1. Create JSON Translation Files

English Translation File Example (en/common.json)

json
{
  "welcome": "Welcome to our application!",
  "greeting": "Hello, {{name}}!",
  "buttons": {
    "submit": "Submit",
    "cancel": "Cancel"
  },
  "itemCount": "{{count}} item",
  "itemCount_plural": "{{count}} items"
}

Chinese Translation File Example (zh/common.json)

json
{
  "welcome": "欢迎使用我们的应用!",
  "greeting": "你好,{{name}}!",
  "buttons": {
    "submit": "提交",
    "cancel": "取消"
  },
  "itemCount": "{{count}} 个项目"
}

zh/home.json and en/home.json files are similar and not shown here.

2. Load JSON Configuration

Create i18n/config.ts configuration file:

typescript
import i18n from "i18next";
import zhCNcommon from "./locales/common.json";
import enCommon from "./locales/common.json";

import zhCNhome from "./locales/home.json";
import enHome from "./locales/home.json";

i18n.init({
  lng: "en", // Default language set to English
  fallbackLng: "en", // Fallback language is also English
  supportedLngs: ["en", "zh-CN"], // Supported languages list
  resources: {
    // Translation resources
    en: {
      // English resources
      translation: enHome, // Default namespace translations
      common: enCommon, // 'common' namespace translations
    },
    "zh-CN": {
      // Simplified Chinese resources
      translation: zhCNhome, // Default namespace translations
      common: zhCNcommon, // 'common' namespace translations
    },
  },
});

export default i18n;

Usage:

ts
console.log(i18n.t("welcome")); // Outputs welcome from default namespace
console.log(i18n.t("common:welcome")); // Outputs welcome from common namespace

TypeScript Type Support

Create i18n.d.ts file for enhanced types:

typescript
// i18n.d.ts
import "i18next";
import enTranslation from "../locales/en/home.json";
import enCommon from "../locales/en/common.json";
// ...other imports

declare module "i18next" {
  interface CustomTypeOptions {
    defaultNS: "translation";
    resources: {
      translation: typeof enTranslation;
      common: typeof enCommon;
    };
  }
}

Summary

Advantages of using pure i18next package in TypeScript client:

  1. Lightweight: No dependency on other frameworks
  2. Powerful Features: Supports plurals, interpolation, context, and other advanced features
  3. Type Safety: Full TypeScript support
  4. Flexible Extensibility: Plugins can be added as needed

Advantages of configuring i18next with JSON:

  1. Separation of Concerns: Translation text is separated from code logic
  2. Easy Maintenance: Non-technical staff can edit translation files
  3. Version Control Friendly: JSON files are easy to manage in version control and collaborate on

With this configuration approach, you can easily manage internationalization needs for large applications while maintaining clean and maintainable code.

神岛实验室