i18n
No modern app can avoid multi-language support. The framework supports internationalization out of the box.
Two things are worth separating before you start:
- Your copy — messages your controllers, validation schemas and email templates produce. You choose the keys and you ship the translations.
- Framework messages — the sentences the built-in middleware and the built-in auth controller return. Each one already carries its English text in code, so it reads correctly with no translation setup at all; you can override any of them key by key.
Translation itself is powered by the i18next library, which is an optional peer dependency — install it only if you actually translate.
:::info Changed in 5.4
Before 5.4, a framework message whose key was missing from your locale files was answered with the raw key (auth.messageSome), and i18next was a mandatory dependency. Now every framework message travels with an English default, so a missing key reads as English, and i18next is optional. A key that is present in your locales still wins, so existing translations are unaffected.
:::
Middlewares
The framework provides an I18n middleware that runs on each HTTP request.
Detectors
As we are talking about languages, we need some way for the codebase to understand what language the user should use.
This feature is called detectors.
Order of detection:
- X-Lang header
- Query
- User
X-Lang Header Detector
This detector will parse the “X-Lang” header on the request to detect the user's language. The frontend should add the user's language here (“en”, ”fr”, etc.), and if the backend supports it, the app will use the given user language.
“xLang” is the preferred way to work with languages in the framework.
Example for the frontend with Fetch:
fetch("https://example.com/getSomething", {
headers: {
"X-Lang": "en", // Added language
},
});
Query Detector
The query is a simple detector. Just add the 'lookupQuerystring' parameter to your query string. 'lookupQuerystring' by default is lng, but you can change it as you want inside the config file.
const res = await fetch("https://someUrl.com?lng=en");
User Detector
The user detector tries to find an authorized user (provided by middleware) and grab the ‘locale’ field from this user.
Adding Your Own Detector
At this time, you are not able to add your own detector. Please contact us if you need that option, and we will be happy to help you.
Configuration
Please look at the ‘config/i18n.ts’ file for all configuration options. There are a limited number of options available.
export default {
enabled: true, // false → no i18next at all; every message is served in English
preload: ["en", "ru"],
supportedLngs: ["en", "ru"], // should be at least one supported
fallbackLng: "en",
saveMissing: false, // see "Finding what is left to translate" below
debug: false,
lookupQuerystring: "lng", // string to detect language on query
};
A language a detector reports that is not in supportedLngs is ignored, and fallbackLng is used instead.
Language Files
All files are JSON, and live in ‘{localeCode}/translation.json’ inside the folder you pass as folders.locales when you construct the Server (‘src/locales’ in the project template):
src/locales/
├─ en/
│ └─ translation.json
└─ ru/
└─ translation.json
Backends besides files are not supported.
Your locale folder is the only one that is loaded. The framework ships locale files for its own tests and for translators to start from, but they are not merged into your app — so a key you do not define is not silently taken from somewhere else. That is exactly why framework messages carry their English text in code.
You can find detailed documentation about JSON files in the i18next JSON documentation.
Translating framework messages
The built-in middleware, the built-in auth controller (its validation messages included) and the shipped account emails all produce their text through a key plus an in-code English default:
- If your locale file for the detected language defines the key, your wording is used.
- If it does not, the English default is used — never the bare key.
So you translate only what you care about, one key at a time, and you never have to copy a full catalog into your project to keep responses readable.
Keys are plain nested JSON — middleware.auth.notLoggedIn is middleware → auth → notLoggedIn:
{
"middleware": {
"auth": {
"notLoggedIn": "Пожалуйста, войдите в приложение"
},
"rateLimiter": {
"tooManyRequests": "Слишком много запросов"
}
},
"auth": {
"errorUPValid": "Неверный логин или пароль",
"emailValid": "Некорректный email"
}
}
A request with X-Lang: ru now gets those four sentences in Russian; everything else still answers in English.
Status codes and machine-readable fields never change with the language. The auth 401 still carries error: "AUTH001" whatever message says — branch on the code, not on the prose.
Middleware keys
| Key | Default (English) |
|---|---|
middleware.auth.notLoggedIn | Please login to application |
middleware.role.userRequired | User should be provided |
middleware.role.noAccess | You do not have access |
middleware.rateLimiter.tooManyRequests | Too Many Requests |
middleware.requestParser.entityTooLarge | Request entity too large. Your upload exceeds the allowed size or count limits. |
middleware.requestParser.parseError | Error to parse your request. You provided invalid content type or content-length. Please check your request headers and content type. |
The rate limiter's 500 RateLimiter error is deliberately not translatable: it reports a misconfigured limiter to operators, not something the caller can act on.
Your own middleware get the same behavior from the translate() helper.
Auth controller, validation and email keys
| Key | Default (English) |
|---|---|
auth.emailProvided | Email must be provided |
auth.emailValid | Email is not valid |
auth.passwordProvided | Password must be provided |
auth.passwordTooShort | Password must be at least {{min}} characters |
auth.passwordTooLong | Password must be at most {{max}} characters |
auth.passwordRecoveryTokenProvided | Password recovery token must be provided |
auth.nickNameValid | Nick name is not valid,only a-z,A-Z,0-9 |
auth.nameValid | Name is not valid |
auth.errorUPValid | User/password not valid |
auth.nicknameExists | User with such nickname already exists |
auth.recoveryEmailSent | If an account exists for this address, a recovery email has been sent. |
auth.verificationEmailSent | If an account exists for this address, a verification email has been sent. |
email.notVerified | Your email is not verified |
email.registered | User with such an email already registered |
email.alreadyVerifiedOrWrongToken | Your email is already verified or your verification token is wrong |
email.passwordRecovery | Recovery password |
email.passwordChanged | Password changed |
email.emailConfirm | Email confirmation |
email.verify | Verify email |
email.verifyInstructions | To verify your email address, follow the link: |
email.greeting | Dear user |
password.wrongToken | Password recovery token is not valid |
Since 5.4.1, built-in registration and password reset accept 15–128 Unicode code points by default, including spaces. Customize these limits with auth.passwordPolicy.minLength and maxLength. Existing login passwords remain valid. The two password-length keys above replace auth.passwordValid; direct model writes and custom password endpoints must enforce their own policy.
Some of the email.* entries are API responses, and some are the actual text of the account emails the framework sends — subject, heading and body of the recovery and verification mails (which template uses which) — so overriding those changes what lands in the mailbox, not just what the API answers.
:::info Changed in 5.4
The verification mail was hardcoded Russian and emitted no keys at all before 5.4. It is now English by default and translatable through email.emailConfirm, email.verify, email.verifyInstructions and email.greeting, like every other framework message. (email.greeating — the pre-5.4 typo spelling — still works as a fallback until v6; translate email.greeting going forward.)
:::
Finding what is left to translate
Set saveMissing: true in ‘config/i18n.ts’ during development. Every framework message that is rendered without a matching key in your locale file is written to ‘{localeCode}/translation.missing.json’ — with its English default as the value. Exercise the flows you care about, then use that file as the starting point for a translator and merge the result into ‘translation.json’.
This is an i18next feature, so it needs the optional packages installed. Keep saveMissing: false in production: it writes files on request.
i18next is optional
i18next and i18next-fs-backend are optional peer dependencies, the same treatment @redis/client, yup and oxc-parser already get. A fresh install has neither, and that is a perfectly valid state: every framework message is served in English, and requests continue to work.
Install both once you actually translate something:
npm i i18next i18next-fs-backend
Without them, the first request that needs a translator logs one warning naming both packages and the enabled flag, then falls back for the rest of the process — it does not repeat per request and it does not fail the request. enabled: true (the default) therefore no longer implies the packages are present.
Setting enabled: false is the explicit way to say "this app is English-only": no i18next is loaded and every framework message still reads as English.
:::info Fixed in framework 5.4.1
Framework 5.4.1 removes mandatory i18next type imports from the core declarations. Consumers can import helpers/appInstance.js, request types, validation and user email APIs with skipLibCheck: false while neither i18next nor i18next-fs-backend is installed. These APIs use the translation types described below.
Framework 5.4.0 reports Cannot find module 'i18next' during declaration checking when the package is absent. Until you upgrade to 5.4.1 or newer, use skipLibCheck: true or install i18next as a workaround. skipLibCheck: true remains an application choice for faster compilation; the fix removes the need to enable it specifically for optional i18n.
:::
API
The framework provides easy integration for controllers. You can grab the i18n instance with:
req.appInfo.i18n;
req.appInfo.i18n.language; // current language
req.appInfo.i18n.t("some.phrase"); // translate some phrase https://www.i18next.com/overview/api#t
Pass a default of your own so a missing key never reaches a client, exactly like the framework does:
req.appInfo.i18n.t("order.cancelled", { defaultValue: "Order cancelled" });
Translation types
Since 5.4.1, TI18n describes the translator available on requests and returned by getI18nForLang(): { t: TranslationFunction; language: string }. Both types, along with TranslationOptions and I18nBaseInstance, are exported from @adaptivestone/framework/services/i18n/types.js. The existing TI18n exports from services/i18n/I18n.js and services/http/middleware/I18n.js remain available.
import type { TI18n } from "@adaptivestone/framework/services/i18n/types.js";
function orderCancelled(i18n: TI18n): string {
return i18n.t("order.cancelled", { defaultValue: "Order cancelled" });
}
Ordinary calls return string and accept string keys, fallback key arrays, an options object with interpolation values, or a positional default (t(key, "Default", options)). Calls requesting returnObjects or returnDetails return unknown; narrow the result before using it. With translations disabled or the peers absent, the fallback returns your default, or the last key in a key array, and does not interpolate values.
The shared types do not include i18next's resource-derived key checking or selector API. Consumers needing those features or additional instance methods can use the full i18next API.
You can pass controller request / query error messages as i18n keys (or plain strings). The framework processes them with i18next before sending the response, regardless of which validator library produced the error — Yup, Zod, Valibot, ArkType, or a custom Standard Schema validator.
class SomeController extends AbstractController {
get routes() {
return {
post: {
"/login": {
handler: this.postLogin,
request: yup.object().shape({
email: yup.string().email().required("auth.emailProvided"), // <-- look here i18n
}),
},
},
};
}
}
A key of yours that no locale file defines is answered as the key itself — the in-code default is a framework-message feature, and your schema has nowhere to put one. Either define the key or use a plain sentence as the message.
Interpolation
Validators that produce parameters (yup's min / max / length, etc.) forward those parameters to i18next, so locale strings can use {{placeholder}} syntax:
// schema
request: yup.object().shape({
password: yup.string().min(8, "auth.passwordTooShort").required(),
});
// locales/en/translation.json
// "passwordTooShort": "Password must be at least {{min}} characters"
// response when password is too short:
// { "errors": { "password": ["Password must be at least 8 characters"] } }
Direct usage.
Sometimes you may want to use i18n outside of HTTP requests (such as for emails, WebSockets, etc.). For that purpose, frameworks provide an easy way to interact with i18n using the same configuration (translations, languages, etc.).
App instance provides lang services for you
import { appInstance } from '@adaptivestone/framework/helpers/appInstance.js';
const i18nService = await appInstance.getI18nService();
const i18n = await i18nService.getI18nForLang(lang);
The returned translator provides language and t() using your i18n configuration.
If i18next is not installed (or enabled is false), you still get a working t() — it returns the defaultValue you pass, or the key when you pass none.
Full i18next API
getI18nBaseInstance() returns the initialized i18next instance. Since 5.4.1, its public type is I18nBaseInstance, exposing translation, cloning and language-detection methods. If you need additional vendor APIs, install both optional packages, import i18next's type in your application and assert it at this boundary:
import type { i18n as I18nextInstance } from "i18next";
import { appInstance } from "@adaptivestone/framework/helpers/appInstance.js";
const service = await appInstance.getI18nService();
const base = (await service.getI18nBaseInstance()) as I18nextInstance;
const translateEnglish = base.getFixedT("en");
The returned runtime object is unchanged. getI18nBaseInstance() throws when the optional packages are missing. getI18nBaseInstanceIfAvailable() returns null for missing packages and logs one warning; other initialization failures still throw.
Validation outside HTTP
ValidateService.validate accepts an optional i18n argument. Pass it to get the same auto-translation the HTTP path provides; omit it to receive raw keys (useful in workers / RPC where you'd rather forward structured errors than translated text).
import { appInstance } from "@adaptivestone/framework/helpers/appInstance.js";
import ValidateService from "@adaptivestone/framework/services/validate/ValidateService.js";
import { ValidationError } from "@adaptivestone/framework/services/validate/ValidationError.js";
async function processQueueMessage(payload, schema) {
const i18nService = await appInstance.getI18nService();
const i18n = await i18nService.getI18nForLang("en");
try {
return await new ValidateService(appInstance, schema).validate(payload, i18n);
} catch (err) {
if (err instanceof ValidationError) {
// err.message → translated wire-shape; err.issues → structured
logger.error({ issues: err.issues }, "queue payload invalid");
}
throw err;
}
}