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
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.
File naming
The file name must be a valid BCP 47 language tag:
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:
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:
- locale in request context — the language set by the middleware through
requestContext
- Accept-Language request header — Language preference sent by the browser/client
- 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" },
};
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)
schema-dsl identifies language codes by filename (without extension). Only files that conform to the standard language code format will be loaded:
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
Strict mode (recommended for production environment)
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:
- Create subdirectory: Create subdirectories under
src/locales/ according to business modules
- Split key: Split the key in the original
.ts file into the .js file in the corresponding subdirectory by module.
- Update configuration: Confirm that
locale.directory in the VextJS configuration points to src/locales/
- Enable strict: Add
strict: true check in CI
- 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
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
Business error code planning
Recommended code segment planning:
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