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:
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
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:
3. Configure language negotiation
4. Use in a route
Run npm run dev, then make these requests in another terminal (use curl.exe in PowerShell):
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():
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:
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:
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
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:
This means i18n is completely optional. app.throw() works fine even without any language pack configured:
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:
- Parse Accept-Language in descending
qorder, ignoringq≤0and*. - In descending quality order, check each candidate for a case-insensitive exact supported match, then a primary-language prefix match.
- Ignore q=0, invalid quality values, and wildcards; preserve request order for equal weights.
- If none match, or
supportedis absent or empty, uselocale.default(defaulten-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.
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.
Language pack organization
Mode A: Flat files (optional)
For small or medium projects, keep messages in one file per language:
i18n-loader scans this directory, identifies language tags from filenames, imports script files, and commits them to the current application's message runtime.
Mode B: Feature modules (recommended default)
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.
Define src/locales/order/payment/en-US.json:
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
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?):
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:
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:
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:
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:
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
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:
Naming suggestions
Business error code planning
Recommended code segment planning:
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
Use in a route
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
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:
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:
4. Proper use of template variables
Dynamic information uses template variables to avoid splicing strings:
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
- Understand locale-related configuration items in Configuration
- Learn how the error message of Parameter Validation is linked to i18n
- See how plugins extends i18n functionality
- Explore Adapter Architecture to learn about request header processing under different Adapters