Internationalization (i18n)

VextJS has built-in internationalization support and realizes multi-language translation of error messages through language pack files in the src/locales/ directory. Language packs are automatically scanned and loaded by i18n-loader, and are seamlessly linked with app.throw() and the schema-dsl verification system.

Basic concepts

The independent metadata middleware negotiates req.locale and, when enabled, requestContext using config.locale.supported and Accept-Language. With no supported match it uses config.locale.default (en-US). requestId.enabled:false only disables IDs and their response headers; locale and fetch.propagateHeaders remain active in production, development, reloads, and createTestApp. req.t remains an optional extension. See Frontend I18n for inheritance and cache boundaries.

This page covers server-side error and validation messages. Frontend page copy uses a separate Frontend i18n feature; req.t() is not injected by default. The core server path is:

app.throw(404, 'user.not_found')
        ↓
  i18n system search key 'user.not_found'
        ↓
  Based on the locale of the current request (resolved from Accept-Language)
        ↓
  Return translated message → "用户不存在" or "User not found"

Quick Start

Prerequisite: a TypeScript API-only project from Quick Start. Keep its startup scripts and tsconfig. Merge the config below into the existing file and create the routes and locale files at the paths shown. No database or authentication plugin is needed.

1. Create a locale directory

mkdir -p src/locales

You may also create it in your editor. In PowerShell, use New-Item -ItemType Directory -Force src/locales.

2. Write locale files

The filename is a BCP 47 language tag:

// src/locales/zh-CN.ts
export default {
  required: "{{#label}} 为必填项",
  "demo.default_only": "仅默认语言的消息",
  "user.not_found": { code: 40001, message: "用户不存在" },
  "user.email_taken": { code: 40002, message: "邮箱已被注册" },
  "auth.token_expired": { code: 40101, message: "登录已过期,请重新登录" },
  "auth.forbidden": { code: 40301, message: "权限不足" },
  "balance.insufficient": {
    code: 20001,
    message: "余额不足,当前余额 {{balance}}",
  },
  "order.limit_exceeded": {
    code: 20002,
    message: "订单数量超出限制,最多 {{max}} 件",
  },
};
// src/locales/en-US.ts
export default {
  required: "{{#label}} is required",
  "user.not_found": { code: 40001, message: "User not found" },
  "user.email_taken": { code: 40002, message: "Email already registered" },
  "auth.token_expired": {
    code: 40101,
    message: "Session expired, please login again",
  },
  "auth.forbidden": { code: 40301, message: "Insufficient permissions" },
  "balance.insufficient": {
    code: 20001,
    message: "Insufficient balance, current: {{balance}}",
  },
  "order.limit_exceeded": {
    code: 20002,
    message: "Order quantity limit exceeded, max {{max}}",
  },
};

3. Configure language negotiation

// src/config/default.ts
export default {
  host: "127.0.0.1",
  port: 3000,
  frontend: { enabled: false },
  locale: { default: "zh-CN", supported: ["zh-CN", "en-US"] },
};

4. Use in a route

// src/routes/i18n-demo.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.get("/not-found", () => app.throw(404, "user.not_found"));
  app.get("/balance", () =>
    app.throw(400, "balance.insufficient", { balance: 50 }),
  );
  app.get("/fallback", () => app.throw(400, "demo.default_only"));
  app.get("/missing-key", () => app.throw(400, "demo.missing"));
  app.post(
    "/validated",
    { validate: { body: { name: "string!" } } },
    (req, res) => res.json(req.valid("body")),
  );
});

Run npm run dev, then make these requests in another terminal (use curl.exe in PowerShell):

curl -i -H "Accept-Language: zh-CN" http://127.0.0.1:3000/i18n-demo/not-found
curl -i -H "Accept-Language: en-US" http://127.0.0.1:3000/i18n-demo/not-found
curl -i -H "Accept-Language: zh-CN" http://127.0.0.1:3000/i18n-demo/balance
curl -i -H "Accept-Language: en-US" http://127.0.0.1:3000/i18n-demo/fallback
curl -i http://127.0.0.1:3000/i18n-demo/missing-key
curl -i -X POST -H "Content-Type: application/json" -H "Accept-Language: en-US" -d '{}' http://127.0.0.1:3000/i18n-demo/validated
RequestHTTP status and response
not-found, zh-CN404, code=40001, message=用户不存在
not-found, en-US404, code=40001, message=User not found
balance, zh-CN400, code=20001, message=余额不足,当前余额 50
fallback, en-US400, message=仅默认语言的消息
missing-key400, message=demo.missing
validated, empty body object422, validation details include the English required-field message for name

demo.default_only intentionally exists only in the default dictionary to test missing-key fallback; real applications should normally complete their translations. Each error response also has the current requestId. Stop dev, run npm run build and npm start, repeat the requests, then stop the service with Ctrl+C.

Later locale snippets illustrate alternative organizations. Merge the fields you need or replace a file only when explicitly stated; do not overwrite the complete dictionary with each subsequent snippet.

Language pack format

File naming

Locale filenames use a two- or three-letter primary language tag with optional region or script subtags, canonicalized through Intl.getCanonicalLocales():

File nameLanguageDescription
zh-CN.tsSimplified Chinese✅ Standard format
en-US.tsAmerican English✅ Standard format
ja-JP.tsJapanese✅ Standard format
ko-KR.tsKorean✅ Standard format
fr.tsFrench✅ Omit region code
de.tsGerman✅ Omit region code
pt-BR.tsBrazilian Portuguese✅ Standard format

Locale data supports .ts, .mts, .cts, .js, .mjs, .cjs and .json. JS module format follows the package's type; use .cjs for CommonJS. Multiple files for one language in the same directory are duplicate sources, not extension overrides.

Non-language files (such as index.ts, README.md, utils.ts) are automatically skipped.

Language pack content

A script locale file exports a dictionary object; JSON uses an object root. A leaf may be a string or an error object with a string message; code is an optional business code and statusCode may specify an HTTP status for shortcut throws. See Locale organization for nested-object flattening:

// src/locales/zh-CN.ts
export default {
  // key: { code: business error code, message: translation message }
  "user.not_found": { code: 40001, message: "用户不存在" },
  "user.email_taken": { code: 40002, message: "邮箱已被注册" },
  "validate.required": { code: 422, message: "{{field}} 不能为空" },
};
Consistency recommendation

Keep code and statusCode the same across languages for a business error, translating only message, so clients can handle it consistently. This is an application convention. The loader checks sources, key conflicts, and format; it does not verify every language's codes match or every business code is globally unique.

Message template variables

Use the {{variableName}} syntax to insert dynamic variables in messages:

// Language pack definition
export default {
  "balance.insufficient": {
    code: 20001,
    message: "余额不足,当前余额 {{balance}} 元",
  },
  "order.limit_exceeded": {
    code: 20002,
    message: "最多购买 {{max}} 件,当前已选 {{current}} 件",
  },
  "file.too_large": {
    code: 20003,
    message: "文件大小不能超过 {{maxSize}}",
  },
};
// Pass in variables in the code
app.throw(400, "balance.insufficient", { balance: 50 });
// → "余额不足,当前余额 50 元"

app.throw(400, "order.limit_exceeded", { max: 10, current: 15 });
// → "最多购买 10 件,当前已选 15 件"

app.throw(400, "file.too_large", { maxSize: "5MB" });
// → "文件大小不能超过 5MB"

app.throw() and i18n

app.throw() is the main entry point for i18n system. Its second parameter (message) also serves as the i18n key to find the translation message.

Basic usage

// Use i18n key - automatic translation
app.throw(404, "user.not_found");
// zh-CN → { "code": 40001, "message": "用户不存在", "requestId": "..." }
// en-US → { "code": 40001, "message": "User not found", "requestId": "..." }

// With variables
app.throw(400, "balance.insufficient", { balance: 50 });
// zh-CN → { "code": 20001, "message": "余额不足,当前余额 50 元", "requestId": "..." }

// With variables and an explicit business-code override
app.throw(400, "balance.insufficient", { balance: 50 }, 20001);

An explicit HTTP status takes precedence over a dictionary statusCode. app.throw("key", params) uses dictionary statusCode, defaulting to 400 if absent. The business code uses an explicit argument first, then dictionary code, then HTTP status if neither exists. To pass both code and details, use the object form in Error Handling.

Downgrade strategy

app.throw() degrades gracefully when i18n lookup fails:

SituationBehavior
Find matching i18n key + languageUse translated message and code
Key found but no current languageCheck the same key in config.locale.default, then use the original message
Key not foundUse original message string directly
No language pack is loadedUse the original message string directly

This means i18n is completely optional. app.throw() works fine even without any language pack configured:

// Without locale files, the message string is returned directly
app.throw(404, "用户不存在");
// → { "code": 404, "message": "用户不存在", "requestId": "..." }

Language detection

The default request metadata middleware negotiates a language and stores it in the request context. Later business middleware may override store.locale. Negotiation proceeds as follows:

  1. Parse Accept-Language in descending q order, ignoring q≤0 and *.
  2. In descending quality order, check each candidate for a case-insensitive exact supported match, then a primary-language prefix match.
  3. Ignore q=0, invalid quality values, and wildcards; preserve request order for equal weights.
  4. If none match, or supported is absent or empty, use locale.default (default en-US).

For supported=["zh-CN", "en-US"], zh;q=1,en-US;q=0.5 selects zh-CN by its higher weight. req.locale is still filled with request context disabled so frontend inheritance works, but context-based error translation uses the app default. Background calls use that default too.

Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
                 ↑ Prioritize the use of zh-CN

Custom language detection middleware

This middleware fragment lets a URL parameter or cookie override the language already negotiated by the framework. Register and attach it as described in Middleware.

// src/middlewares/detect-locale.ts
import { defineMiddleware, requestContext } from "vextjs";

export default defineMiddleware(async (req, _res, next) => {
  const locale =
    typeof req.query.lang === "string" ? req.query.lang : req.cookie("lang");
  const store = requestContext.getStore();
  if (locale && ["zh-CN", "en-US"].includes(locale)) {
    req.locale = locale;
    if (store) store.locale = locale;
  }
  await next();
});

Language pack organization

Mode A: Flat files (optional)

For small or medium projects, keep messages in one file per language:

src/locales/
├── zh-CN.ts # Chinese messages
└── en-US.ts # English messages
// src/locales/zh-CN.ts
export default {
  // User module
  "user.not_found": { code: 40001, message: "用户不存在" },
  "user.email_taken": { code: 40002, message: "该邮箱已被注册" },

  // Authentication module
  "auth.unauthorized": { code: 40100, message: "请先登录" },
  "auth.forbidden": { code: 40300, message: "权限不足" },

  // Order module
  "order.not_found": { code: 40004, message: "订单不存在" },
  "order.cancelled": {
    code: 40005,
    message: "订单已取消,无法继续操作",
  },

  // General
  "server.error": {
    code: 50000,
    message: "服务器开小差了,请稍后重试",
  },
};

i18n-loader scans this directory, identifies language tags from filenames, imports script files, and commits them to the current application's message runtime.

This is a suggested layout; projects can use their own architecture. One VextJS loader handles flat and module files. Each app owns its dictionary, so reload and disposal do not change another app's messages.

src/locales/
├── zh-CN.ts                # Optional common messages
├── en-US.ts
├── account/
│   ├── zh-CN.json
│   └── en-US.json
└── order/
    └── payment/            # Second feature level
        ├── zh-CN.json
        └── en-US.json

Define src/locales/order/payment/en-US.json:

{
  "declined": {
    "code": 20001,
    "message": "Insufficient balance: {{balance}}",
    "statusCode": 409
  },
  "receipt": { "title": "Payment receipt" }
}
app.throw("order.payment.declined", { balance: 5 });
// HTTP 409, business code 20001, message "Insufficient balance: 5"
Source and keyFinal key
declined in order/payment/en-US.jsonorder.payment.declined
Nested object receipt.title in that fileorder.payment.receipt.title
A top-level dotted key user.not_found in any fileuser.not_found
A top-level numeric business key 20001 in any file20001

Strings and error objects containing message are leaves. Error code and statusCode metadata is retained; other objects are flattened recursively. Duplicate locale and final-key pairs reject startup with both sources. Extensions, language-tag case and Unicode path aliases cannot bypass conflict detection.

Configuration

// src/config/default.ts
export default {
  locale: {
    default: "en-US",
    supported: ["en-US", "zh-CN"],
    directory: "src/locales",
  },
};

directory is relative to the service root or an explicit absolute path. The default src/locales can be omitted. Directories inside src/ follow the actual compiled output location. Explicit shared directories retain their read location and must be available at runtime. The framework loads application dictionaries into an app runtime; global dsl.config({ i18n: ... }) does not configure that dictionary.

Application dictionary API

Normal startup uses the locale configuration above. Custom application assembly or explicit dictionary replacement can use the root export loadI18n(app, directory, options?):

import { loadI18n, type VextApp } from "vextjs";

export async function replaceApplicationMessages(
  app: VextApp,
  directory: string,
) {
  return loadI18n(app, directory); // Returns the sorted language list.
}

app must be a framework-created application. Optional options.rootDir identifies the service root, and options.compiled selects compiled input. Only a complete validated dictionary replaces that application's messages. Read or format errors preserve the old dictionary; a missing directory clears it. JSON does not execute code, while script dictionaries execute as modules; this API is not static inspection. Applications retain separate dictionaries without exposing an internal replacement callback.

Failure and reload

vext dev watches additions, changes and deletions of both language modules and JSON, including a custom locale.directory. Directories inside src/ use compilation and soft reload. Changes to explicitly configured locale or model read directories outside the source root trigger a cold restart to refresh native module dependencies as well. The watcher receives resolved directory data without executing the configuration provider again.

A complete validated candidate replaces the dictionary in one step. Import errors, invalid dictionaries and duplicate keys never commit a partial dictionary. Development reports the failure and uses its existing recovery flow. Deleting a file or directory removes its messages. JSON language files and code modules participate in build and change handling.

The frontend has a separate src/frontend/locales/ source set. It shares directory naming and conflict checks while retaining object access: the module's nested receipt.title is read as useVextI18n().order.payment.receipt.title. Explicit dotted keys still use bracket access. Backend messages are not exposed to the browser by a similar directory name. CI should start the real application and test language negotiation, translation, missing-key fallback and HTTP status codes.

Used in service layer

The service layer can throw translated errors through this.app.throw(). The database and balance methods below are placeholders that only show where to throw errors; replace them with your actual business implementation:

// src/services/user.ts
import type { VextApp } from "vextjs";

export default class UserService {
  constructor(private app: VextApp) {}

  async findById(id: string) {
    const user = await this.queryDatabase(id);
    if (!user) {
      this.app.throw(404, "user.not_found");
    }
    return user;
  }

  async create(data: { name: string; email: string }) {
    const existing = await this.findByEmail(data.email);
    if (existing) {
      this.app.throw(409, "user.email_taken");
    }

    const user = await this.insertDatabase(data);
    return user;
  }

  async withdraw(userId: string, amount: number) {
    const balance = await this.getBalance(userId);
    if (balance < amount) {
      // Interpolate a balance variable.
      this.app.throw(400, "balance.insufficient", { balance });
    }
    // ...
  }

  private async queryDatabase(id: string) {
    return null;
  }
  private async findByEmail(email: string) {
    return null;
  }
  private async insertDatabase(data: any) {
    return data;
  }
  private async getBalance(userId: string) {
    return 0;
  }
}

Linkage with schema-dsl verification

The default validator uses the current app runtime and request language. Use installed schema-dsl message keys, such as required, min and max, with upstream parameters {{#label}} and {{#limit}}. Keep these overrides in a top-level file so no feature namespace is added:

// src/locales/en-US.ts
export default {
  required: "{{#label}} is required",
  min: "{{#label}} must have at least {{#limit}} characters",
};

A business key such as validate.required does not automatically replace the upstream required message. Dictionaries do not determine validation HTTP statuses: path parameter failures use 400; query/header/cookie/body failures use 422. A validator installed through app.setValidator() owns its translation behavior.

Configuration options

config.locale

Specify the default language in the configuration:

// src/config/default.ts
export default {
  locale: { default: "en-US", supported: ["en-US", "zh-CN"] },
};

Use this configuration as a fallback when the language cannot be detected from the request.

Loading process

Startup timing

i18n-loader is executed in the early stages of bootstrap:

1. config → load configuration
2. locales → ⭐ Load language pack (here)
3. plugins → execute plugin setup()
4. middlewares → scanning middleware
5. services → instantiated services
6. routes → Register routes

Standard startup loads dictionaries before plugins and services. Calls made during requests use the negotiated language; calls outside a request, such as plugin initialization, use the application's default language. Dictionary loading failure stops that initialization.

Loading behavior

SituationBehavior
Missing or empty directoryInstall an empty dictionary; reload clears removed messages
Invalid module export or import failureFail startup; reject the reload candidate and retain previous messages
Invalid JSON, duplicate source or final-key conflictReport sources and reject the candidate
Valid candidateReplace the full dictionary and log loaded languages

File conflicts

zh-CN.ts and zh-CN.json in the same directory are duplicate sources; extension priority does not override one. The same language may appear in different feature directories, with unique final keys. App A's dictionary, default language and disposal do not affect app B. Background calls use their app's default language.

Key naming convention

Prefer dotted module.action keys:

module.specific_error
user.not_found
user.email_taken
auth.token_expired
order.already_cancelled
balance.insufficient
file.too_large
validate.required

Naming suggestions

RulesExamplesDescription
Use lowercase + underscoreuser.not_found ✅Avoid case confusion
Group by moduleuser.*, order.*Easy to manage and find
Use descriptive namesuser.email_taken ✅Don’t use user.error_1
Keep keys shortauth.forbidden ✅Don’t be too verbose
code is globally unique40001, 40002, ...Different keys use different codes

Business error code planning

Recommended code segment planning:

ScopeModuleDescription
400xxUser related40001 The user does not exist, 40002 The email address has been registered...
401xxAuthentication related40100 Not logged in, 40101 token expired...
403xxPermission related40300 Insufficient permissions, 40301 IP is banned...
200xxBusiness logic20001 Insufficient balance, 20002 Quantity exceeded...
422Request validationQuery/header/cookie/body failures; invalid path params use HTTP 400
500xxSystem error50000 Internal error, 50001 External service timeout...

Advanced example: translating order errors

This order scenario shows how to organize dictionaries, an authenticated route and a Service. It requires a configured auth middleware, the bearerAuth documentation definition and business storage; see Security and Services for those prerequisites. To test only i18n, run the independent Quick Start above. The fixed product price and balance below demonstrate error branches; the example does not persist orders.

Language packs

// src/locales/zh-CN.ts
export default {
  // ── User ──
  "user.not_found": { code: 40001, message: "用户不存在" },
  "user.email_taken": { code: 40002, message: "该邮箱已被注册" },
  "user.disabled": { code: 40003, message: "账号已被禁用,请联系管理员" },

  // ── Auth ──
  "auth.unauthorized": { code: 40100, message: "请先登录" },
  "auth.token_expired": { code: 40101, message: "登录已过期,请重新登录" },
  "auth.invalid_token": { code: 40102, message: "无效的登录凭证" },
  "auth.forbidden": { code: 40301, message: "权限不足,需要 {{role}} 角色" },

  // ── Order ──
  "order.not_found": { code: 40004, message: "订单不存在" },
  "order.already_paid": { code: 40005, message: "订单已支付,请勿重复操作" },
  "order.cancelled": { code: 40006, message: "订单已取消" },
  "order.limit_exceeded": {
    code: 20002,
    message: "单次最多购买 {{max}} 件商品",
  },

  // ── Payment ──
  "balance.insufficient": {
    code: 20001,
    message: "余额不足,当前余额 {{balance}} 元,需要 {{required}} 元",
  },
  "payment.failed": { code: 20003, message: "支付失败,请稍后重试" },
  "payment.timeout": { code: 20004, message: "支付超时,请检查支付状态" },

  // ── General ──
  "server.error": { code: 50000, message: "服务器开小差了,请稍后重试" },
  "server.maintenance": {
    code: 50001,
    message: "系统维护中,预计 {{time}} 恢复",
  },
};
// src/locales/en-US.ts
export default {
  // ── User ──
  "user.not_found": { code: 40001, message: "User not found" },
  "user.email_taken": { code: 40002, message: "Email already registered" },
  "user.disabled": {
    code: 40003,
    message: "Account has been disabled, please contact admin",
  },

  // ── Auth ──
  "auth.unauthorized": { code: 40100, message: "Please login first" },
  "auth.token_expired": {
    code: 40101,
    message: "Session expired, please login again",
  },
  "auth.invalid_token": { code: 40102, message: "Invalid credentials" },
  "auth.forbidden": {
    code: 40301,
    message: "Insufficient permissions, {{role}} role required",
  },

  // ── Order ──
  "order.not_found": { code: 40004, message: "Order not found" },
  "order.already_paid": { code: 40005, message: "Order already paid" },
  "order.cancelled": { code: 40006, message: "Order has been cancelled" },
  "order.limit_exceeded": {
    code: 20002,
    message: "Maximum {{max}} items per order",
  },

  // ── Payment ──
  "balance.insufficient": {
    code: 20001,
    message:
      "Insufficient balance. Current: {{balance}}, required: {{required}}",
  },
  "payment.failed": {
    code: 20003,
    message: "Payment failed, please try again later",
  },
  "payment.timeout": {
    code: 20004,
    message: "Payment timeout, please check payment status",
  },

  // ── General ──
  "server.error": {
    code: 50000,
    message: "Internal server error, please try again later",
  },
  "server.maintenance": {
    code: 50001,
    message: "System maintenance in progress, estimated recovery at {{time}}",
  },
};

Use in a route

// src/routes/orders.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.post(
    "/",
    {
      validate: {
        body: {
          productId: "string!",
          quantity: "number:1-99!",
        },
      },
      middlewares: ["auth"],
      auth: { required: true, security: "bearerAuth" },
      docs: { summary: "Create order" },
    },
    async (req, res) => {
      const data = req.valid("body");
      const userId = req.auth?.userId;
      if (typeof userId !== "string") app.throw(401, "auth.unauthorized");

      const order = await app.services.order.create(userId, data);
      res.json(order, 201);
    },
  );
});
// src/services/order.ts
import { randomUUID } from "node:crypto";
import type { VextApp } from "vextjs";

export default class OrderService {
  constructor(private app: VextApp) {}

  async create(userId: string, data: { productId: string; quantity: number }) {
    // Check the product
    const product = await this.findProduct(data.productId);
    if (!product) {
      this.app.throw(404, "order.not_found");
    }

    // Check the quantity limit
    if (data.quantity > 10) {
      this.app.throw(400, "order.limit_exceeded", { max: 10 });
    }

    // Check the balance
    const balance = await this.getBalance(userId);
    const required = product.price * data.quantity;
    if (balance < required) {
      this.app.throw(400, "balance.insufficient", { balance, required });
      // zh-CN → "余额不足,当前余额 50 元,需要 100 元"
      // en-US → "Insufficient balance. Current: 50, required: 100"
    }

    // Create the order...
    return { orderId: randomUUID(), status: "pending" };
  }

  private async findProduct(id: string) {
    return id === "demo" ? { id, price: 10, name: "Sample Product" } : null;
  }

  private async getBalance(userId: string) {
    return 50;
  }
}

After authentication, productId: "demo" with quantity 1 returns 201. Quantity 6 returns 400 with the interpolated insufficient-balance message; quantity 11 returns 400 for the purchase limit. Any other product ID returns 404. Your business rules should also require an integer quantity; this snippet focuses on translation branches.

Troubleshooting untranslated messages

SymptomCheck and fixVerify again
Always uses the default languageCheck that supported includes the requested language, request context is enabled, and Accept-Language matchesCompare the Chinese and English not-found requests above
Returns the key itselfCheck the final key, requested and default dictionaries, module namespace and loaded directoryCompare a missing key with a known key
Startup reports duplicate source or keyRemove the conflict using both sources in the diagnostic; file extensions do not override each otherRestart and request both languages
Variables are not interpolatedMatch business {{name}} placeholders with parameters; validation messages use upstream placeholders such as {{#label}}Request a balance error and an invalid empty object
Changes in development still show old textInspect reload logs, fix an invalid dictionary and trigger reload again or restartRepeat the request; an old working dictionary does not prove reload succeeded

Best Practices

1. Plan stable business codes

If clients distinguish errors by business code, assign different codes to different meanings. The framework allows multiple errors to share an HTTP status, but clients cannot distinguish them by that status alone:

// ✅ Correct — use different codes for different errors
'user.not_found': { code: 40001, message: '...' },
'user.email_taken': { code: 40002, message: '...' },

// Sharing code 400 works, but clients then need business context to distinguish errors.
'user.not_found': { code: 400, message: '...' },
'user.email_taken': { code: 400, message: '...' },

2. Maintain language files together

When adding a key, update each language pack. A missing key in the requested language falls back to the application's default language; only when both lack the key does the original message appear.

During Review, compare meaning, keys and business codes. Use existing tests for language negotiation, missing-key fallback and error status. Equal key sets alone do not establish translation quality.

3. Messages are written for users

i18n messages are ultimately displayed to the end user and should be in plain language:

// ✅ User friendly
{
  message: "Insufficient balance, current balance is {{balance}} yuan";
}
{
  message: "Login has expired, please log in again";
}

// ❌ Technical expression
{
  message: "InsufficientBalanceException: current={{balance}}";
}
{
  message: "JWT token expired at timestamp";
}

4. Proper use of template variables

Dynamic information uses template variables to avoid splicing strings:

// ✅ Use template variables
{
  message: "Buy up to {{max}} items";
}
app.throw(400, "order.limit_exceeded", { max: 10 });

// ❌ Avoid splicing
app.throw(400, `Maximum purchase of ${max} items`); // Unable i18n

5. i18n is optional

Projects that do not require i18n need no locales/ directory. Without a language pack, app.throw() uses the original message string.

Configuring i18n is only required if your API needs to target multi-language clients.

Next step