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

VextJS's i18n system focuses on multilingualization of error messages rather than full-site copy translation. Core process:

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 → "User does not exist" or "User not found"

Quick Start

1. Create language pack directory

mkdir -p src/locales

2. Write language pack file

The file name is the language code (BCP 47 format):

// src/locales/zh-CN.ts
export default {
  "user.not_found": { code: 40001, message: "The user does not exist" },
  "user.email_taken": { code: 40002, message: "Email has been registered" },
  "auth.token_expired": { code: 40101, message: "Login has expired, please log in again" },
  "auth.forbidden": { code: 40301, message: "Insufficient permissions" },
  "balance.insufficient": {
    code: 20001,
    message: "Insufficient balance, current balance {{balance}}",
  },
  "order.limit_exceeded": {
    code: 20002,
    message: "The order quantity exceeds the limit, up to {{max}} pieces",
  },
};
// src/locales/en-US.ts
export default {
  "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. Use in code

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

export default defineRoutes((app) => {
  app.get(
    "/:id",
    {
      validate: { param: { id: "string!" } },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const user = await app.services.user.findById(id);

      if (!user) {
        // Automatically return messages in the corresponding language based on the requested Accept-Language
        app.throw(404, "user.not_found");
      }

      res.json(user);
    },
  );
});

It's that simple! The language pack is automatically loaded when the framework starts, and app.throw() automatically matches the currently requested language.

Language pack format

File naming

The file name must be a valid BCP 47 language tag:

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

Supported file extensions (in order of priority): .ts.js.mjs.cjs

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

Language pack content

Each language pack file exports an object using export default. The key is the error identifier, and the value contains code (business error code) and message (translated message):

// src/locales/zh-CN.ts
export default {
  // key: { code: business error code, message: translation message }
  "user.not_found": { code: 40001, message: "The user does not exist" },
  "user.email_taken": { code: 40002, message: "Email has been registered" },
  "validate.required": { code: 422, message: "{{field}} cannot be empty" },
};
consistency requirements

The code value of the same key in different language packages must be consistent. code is a globally unique business error code, independent of language. Only message differs from language to language.

Message template variables

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

// Language pack definition
export default {
  "balance.insufficient": {
    code: 20001,
    message: "Insufficient balance, current balance is {{balance}} yuan",
  },
  "order.limit_exceeded": {
    code: 20002,
    message: "Maximum purchase of {{max}} items, currently {{current}} items selected",
  },
  "file.too_large": { code: 20003, message: "The file size cannot exceed {{maxSize}}" },
};
// Pass in variables in the code
app.throw(400, "balance.insufficient", { balance: 50 });
// → "Insufficient balance, current balance is 50 yuan"app.throw(400, "order.limit_exceeded", { max: 10, current: 15 });
// → "Maximum purchase is 10 items, currently 15 items have been selected"

app.throw(400, "file.too_large", { maxSize: "5MB" });
// → "File size cannot exceed 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": "The user does not exist", "requestId": "..." }
// en-US → { "code": 40001, "message": "User not found", "requestId": "..." }

//With variables
app.throw(400, "balance.insufficient", { balance: 50 });
// zh-CN → { "code": 20001, "message": "Insufficient balance, current balance is 50 yuan", "requestId": "..." }

//With variables + business error code coverage
app.throw(400, "balance.insufficient", { balance: 50 }, 20001);

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 languageTried fallback language, ended up using 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:

// When there is no language pack, the message string is directly used as the response message
app.throw(404, "User does not exist");
// → { "code": 404, "message": "User does not exist", "requestId": "..." }

Language detection

The framework determines the language of the current request (in order of priority) by:

  1. locale in request context — the language set by the middleware through requestContext
  2. Accept-Language request header — Language preference sent by the browser/client
  3. Default language in configuration — The default language specified by config.locale
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

If you need to detect the language from other sources (such as URL parameters, cookies, user settings, etc.), you can write custom middleware:

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

export default defineMiddleware(async (req, res, next) => {
  // Priority: URL Parameters > Cookie > Accept-Language
  let locale =
    req.query.lang ||
    parseCookie(req.headers["cookie"])?.lang ||
    parseAcceptLanguage(req.headers["accept-language"]);

  //Write request context (requires requestContext.enabled = true)
  // Subsequent app.throw() will automatically use this locale
  (req as any).locale = locale || "zh-CN";

  await next();
});

function parseCookie(cookie?: string) {
  if (!cookie) return {};
  return Object.fromEntries(cookie.split(";").map((c) => c.trim().split("=")));
}

function parseAcceptLanguage(header?: string) {
  if (!header) return undefined;
  return header.split(",")[0]?.split(";")[0]?.trim();
}

Language pack organization

Mode A: Tile files (default)

Suitable for small and medium-sized projects, all error messages are concentrated in one file:

src/locales/
├── zh-CN.ts # All Chinese news
└── en-US.ts # All messages in English
// src/locales/zh-CN.ts
export default {
  //User module
  "user.not_found": { code: 40001, message: "The user does not exist" },
  "user.email_taken": { code: 40002, message: "Email has been registered" },

  // Authentication module
  "auth.unauthorized": { code: 40100, message: "Please log in first" },
  "auth.forbidden": { code: 40300, message: "Insufficient permissions" },

  // order module
  "order.not_found": { code: 40004, message: "Order does not exist" },
  "order.cancelled": { code: 40005, message: "The order has been canceled and cannot be operated" },

  // Universal
  "server.error": { code: 50000, message: "Server internal error, please try again later" },
};

i18n-loader automatically scans this directory, identifies the language code by file name, dynamically imports and registers it with the i18n system of schema-dsl.

Mode B: Subdirectory mode (large projects)

Requirements: schema-dsl 2.x

Suitable for large-scale projects with multi-person collaboration, language packages are split according to business modules. Each subdirectory is maintained independently by different developers. schema-dsl automatically scans all subdirectories recursively at startup and merges them into a unified language package.

Directory structure

src/locales/
├── zh-CN.js # Top-level public message (optional)
├──en-US.js
├── core/ #Public code section (framework layer maintenance)
│ ├── zh-CN.js
│ └── en-US.js
├── account/ # Account module (maintained by developer A)
│ ├── zh-CN.js
│ └── en-US.js
├── order/ # Order module (maintained by developer B)
│ ├── zh-CN.js
│ └── en-US.js
└── modules/ # Support deep nesting
    └── payment/
        ├── zh-CN.js
        └──en-US.js
The subdirectory name is only for the organization layer

Subdirectory names (such as core/, account/, order/) do not affect the final key naming. Language files in all subdirectories are eventually merged into the same flat key-value map. Therefore:

  • { 'server.error': ... } in core/zh-CN.js → the final key is server.error
  • { 'user.not_found': ... } in account/zh-CN.js → the final key is user.not_found

It is recommended to use key prefixes (such as user., order.) to distinguish module ownership instead of relying on directory names.

Configuration method

Subdirectory mode does not go through VextJS's i18n-loader (which only scans tiled files), but through schema-dsl's built-in recursive scanning functionality. There are three equivalent configuration methods:

// src/config/default.ts
import path from "node:path";

export default {
  // Method 1 (recommended): Pass string path → schema-dsl automatic recursive scan
  locale: {
    default: "zh-CN",
    supported: ["zh-CN", "en-US"],
    directory: path.join(__dirname, "src/locales"),
  },
};
// Method 2: Configure through the schema-dsl API in the plug-in
import { dsl } from "schema-dsl";

dsl.config({
  i18n: "./src/locales", // String path, automatic recursive scanning
});
// Method 3: Use localesPath object form
import { dsl } from "schema-dsl";

dsl.config({
  i18n: { localesPath: "./src/locales" },
});

Language file example

// src/locales/core/zh-CN.js
module.exports = {
  "server.error": { code: 50000, message: "Server internal error, please try again later" },
  "server.maintenance": { code: 50300, message: "The system is under maintenance, please visit later" },
};
// src/locales/account/zh-CN.js
module.exports = {
  "user.not_found": { code: 40001, message: "The user does not exist" },
  "user.email_taken": { code: 40002, message: "Email has been registered" },
  "user.disabled": { code: 40003, message: "Account has been disabled" },
};
// src/locales/order/zh-CN.js
module.exports = {
  "order.not_found": { code: 40004, message: "Order does not exist" },
  "order.limit_exceeded": {
    code: 40005,
    message: "Order quantity exceeded limit (maximum {{max}} pieces)",
  },
};
// src/locales/account/en-US.js
module.exports = {
  "user.not_found": { code: 40001, message: "User not found" },
  "user.email_taken": { code: 40002, message: "Email already taken" },
  "user.disabled": { code: 40003, message: "Account has been disabled" },
};

File format restrictions

The schema-dsl built-in scanning only supports .js and .json formats, and does not support .ts.

  • If the project uses TypeScript, it is recommended to use the .js format in the src/locales/ subdirectory (the language package itself is pure data and does not require type support)
  • Top-level tile files can still use .ts (loaded by VextJS's i18n-loader)

File name format check

schema-dsl identifies language codes by filename (without extension). Only files that conform to the standard language code format will be loaded:

File nameWhether to loadDescription
zh-CN.jsStandard format
en-US.jsStandard format
ja.jsShort form (no region code)
fr-FR.jsonJSON format
index.jsDoes not comply with the language code format
utils.jsDoes not conform to the language code format
README.jsDoes not conform to the language code format

This means that you can safely place tool files such as index.js (export summary) in subdirectories without being accidentally loaded.

Key conflict detection

When the same key is defined in the same language file in multiple subdirectories, schema-dsl provides two conflict handling strategies:

Default mode (relaxed)

The file loaded later overwrites the value loaded earlier and outputs WARN on the console:

[schema-dsl] i18n key conflict in locale 'zh-CN'
  Conflict key: user.not_found
  Source file: /app/src/locales/account/zh-CN.js

After strict: true is enabled, if a key conflict is encountered, an Error will be thrown directly to block the startup to prevent silent coverage online:

import { dsl } from "schema-dsl";

dsl.config({
  i18n: "./src/locales",
  strict: true, // When 🔴 key conflicts, an error is thrown and the startup is blocked.
});

The error message thrown includes conflicting key names, language codes and source file paths for quick location:

Error: [schema-dsl] i18n key conflict in locale 'zh-CN'
  Conflict key: user.not_found
  Source file: /app/src/locales/account/zh-CN.js
Recommended practices
  • Development Environment: Use the default mode (relaxed) to facilitate rapid iteration
  • Production/CI: Enable strict: true to detect conflicts before deployment :::

Merge strategy

Loading order:
1. Top-level file (src/locales/zh-CN.js)
2. Scan subdirectories in alphabetical order (account/ → core/ → order/)
3. Recursively enter deep subdirectories (modules/payment/, etc.)

Merge rules:
- All files of the same language are merged into a flat Map
- Files loaded later can overwrite keys with the same name loaded first (relaxed mode)
- Keys with the same name in strict mode are directly blocked

CI verification recommendations

Add pre-start verification in the CI pipeline to ensure that there will be no language package conflicts or omissions when multiple people collaborate:

# .github/workflows/ci.yml
- name: Validate i18n keys
  run: |
    node -e "
      const { dsl } = require('schema-dsl');
      dsl.config({ i18n: './src/locales', strict: true });
      console.log('✅ i18n key conflict detection passed');
    "

You can also write a more complete verification script to check whether the keys of each language file are aligned (that is, the keys that zh-CN has, en-US must also have):

// scripts/check-i18n.js
const { dsl, Locale } = require("schema-dsl");

dsl.config({ i18n: "./src/locales", strict: true });

const locales = Object.keys(Locale.locales);
if (locales. length < 2) {
  console.log("⚠️ Only 1 language detected, skipping alignment check");
  process.exit(0);
}

const [base, ...rest] = locales;
const baseKeys = Object.keys(Locale.locales[base]).sort();

for (const locale of rest) {
  const keys = Object.keys(Locale.locales[locale]).sort();
  const missing = baseKeys.filter((k) => !keys.includes(k));
  const extra = keys.filter((k) => !baseKeys.includes(k));

  if (missing. length > 0) {
    console.error(`❌ ${locale} is missing key: ${missing.join(", ")}`);
    process.exitCode = 1;
  }
  if (extra.length > 0) {
    console.warn(`⚠️ ${locale} has extra key: ${extra.join(", ")}`);
  }
}

if (!process.exitCode) {
  console.log(`✅ All languages (${locales.join(", ")}) key alignment check passed`);
}

Migrate from tile mode to subdirectory mode

If your project is already using tiling mode (mode A), you can follow these steps to migrate smoothly:

  1. Create subdirectory: Create subdirectories under src/locales/ according to business modules
  2. Split key: Split the key in the original .ts file into the .js file in the corresponding subdirectory by module.
  3. Update configuration: Confirm that locale.directory in the VextJS configuration points to src/locales/
  4. Enable strict: Add strict: true check in CI
  5. Delete old files: After confirming that all keys have been migrated, delete the top-level .ts file

:::warning Note on mixed use Top-level tile files (.ts, loaded by VextJS i18n-loader) and subdirectory files (.js, loaded by schema-dsl recursive scan) can coexist. The keys of both will be merged into the same language pack. But be careful to avoid key conflicts - it is recommended to keep only one schema after the migration is complete.

Used in service layer

The service layer can throw i18n error messages via this.app.throw():

// 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) {
      //With interpolation variables
      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

Validation error messages for schema-dsl also support i18n. Translations for validation errors can be provided in language packs:

// src/locales/zh-CN.ts
export default {
  //Verify error message
  "validate.required": { code: 422, message: "{{field}} is required" },
  "validate.min_length": {
    code: 422,
    message: "{{field}} cannot be less than {{min}} characters in length",
  },
  "validate.max_length": {
    code: 422,
    message: "{{field}} cannot be longer than {{max}} characters",
  },
  "validate.invalid_email": { code: 422, message: "Please enter a valid email address" },
  "validate.out_of_range": {
    code: 422,
    message: "{{field}} must be between {{min}} and {{max}}",
  },

  //Business error message
  "user.not_found": { code: 40001, message: "The user does not exist" },
  // ...
};

Configuration options

config.locale

Specify the default language in the configuration:

// src/config/default.ts
export default {
  locale: "zh-CN", //Default language
};

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

Language packs are loaded before plugins and services, ensuring that app.throw() works with i18n throughout the application lifecycle.

Loading behavior

SituationBehavior
src/locales/ does not existSkip silently without reporting an error (zero configuration scenario)
Directory is emptySkip silently
The file has no default exportSkip the file and print a warning
File import failedSkip the file and print a warning (does not block startup)
Normal loadingLog output loaded language list
INFO [vextjs] i18n loaded: zh-CN, en-US

File priority

When there are multiple extensions for the same language code, the first one is selected according to priority:

zh-CN.ts ← Priority
zh-CN.js
zh-CN.mjs
zh-CN.cjs

Key naming convention

It is recommended to use the dotted format of module.action to name the i18n key:

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_foundAvoid case confusion
Group by moduleuser.*, order.*Easy to manage and find
Use descriptive namesuser.email_takenDon’t use user.error_1
Keep keys shortauth.forbiddenDon’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...
422Verification errorUniformly use HTTP 422 status code
500xxSystem error50000 Internal error, 50001 External service timeout...

Complete example

Language pack

// src/locales/zh-CN.ts
export default {
  // ── User module ──
  "user.not_found": { code: 40001, message: "The user does not exist" },
  "user.email_taken": { code: 40002, message: "This email has been registered" },
  "user.disabled": { code: 40003, message: "The account has been disabled, please contact the administrator" },

  //──Authentication module──
  "auth.unauthorized": { code: 40100, message: "Please log in first" },
  "auth.token_expired": { code: 40101, message: "Login has expired, please log in again" },
  "auth.invalid_token": { code: 40102, message: "Invalid login credentials" },
  "auth.forbidden": { code: 40301, message: "Insufficient permissions, {{role}} required" },

  //──Order module──
  "order.not_found": { code: 40004, message: "Order does not exist" },
  "order.already_paid": { code: 40005, message: "The order has been paid, please do not repeat the operation" },
  "order.cancelled": { code: 40006, message: "Order has been canceled" },
  "order.limit_exceeded": {
    code: 20002,
    message: "A maximum of {{max}} items can be purchased at a time",
  },// ── Payment module ──
  "balance.insufficient": {
    code: 20001,
    message: "The balance is insufficient, the current balance is {{balance}} yuan, and {{required}} yuan is needed",
  },
  "payment.failed": { code: 20003, message: "Payment failed, please try again later" },
  "payment.timeout": { code: 20004, message: "Payment timeout, please check the payment status" },

  // ── General ──
  "server.error": { code: 50000, message: "The server is out of service, please try again later" },
  "server.maintenance": {
    code: 50001,
    message: "The system is under maintenance and is expected to be restored in {{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 canceled" },
  "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}}",
  },
};

Used in routing

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

export default defineRoutes((app) => {
  app.post(
    "/",
    {
      validate: {
        body: {
          productId: "string!",
          quantity: "number:1-99!",
        },
      },
      middlewares: ["auth"],
      docs: { summary: "Create order" },
    },
    async (req, res) => {
      const data = req.valid("body");
      const userId = (req as any).user.id;

      try {
        const order = await app.services.order.create(userId, data);
        res.json(order, 201);
      } catch (err) {
        // Errors thrown by app.throw() will be automatically caught by the framework
        // i18n translation takes effect automatically
        throw err;
      }
    },
  );
});
// src/services/order.ts
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 quantity limit
    if (data.quantity > 10) {
      this.app.throw(400, "order.limit_exceeded", { max: 10 });
    }

    // Check 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 → "Insufficient balance, current balance is 50 yuan, 100 yuan is needed"
      // en-US → "Insufficient balance. Current: 50, required: 100"
    }//Create order...
    return { orderId: crypto.randomUUID(), status: "pending" };
  }

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

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

Best Practices

1. Keep code globally unique

Each i18n key corresponds to a unique business error code, which facilitates precise processing by the front end based on the code:

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

// ❌ Error — use the same code for different errors
'user.not_found': { code: 400, message: '...' },
'user.email_taken': { code: 400, message: '...' },

2. Synchronously maintain multi-language files

When adding a new i18n key, ensure that all language packs are updated simultaneously. Missing keys will cause the language to be downgraded to the original message.

It is recommended to add a check script in CI to verify whether the key sets of all language packages are consistent.

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 do not need to create a locales/ directory at all. app.throw() uses the original message string directly when there is no language pack, and the function is completely normal.

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

Next step