Internationalization (i18n)
Built-in i18n support with automatic locale detection, fallback chains, and JSON-based translation files.Overview
┌─────────────────────────────────────────────────────────────┐
│ i18n Request Flow │
│ │
│ Request ──▶ Detect Locale ──▶ Load Translations ──▶ Format │
│ │ │ │
│ ▼ ▼ │
│ 1. Accept-Language en.json │
│ 2. X-Locale ms.json │
│ 3. Cookie zh.json │
│ 4. Query param (fallbacks) │
│ 5. Default locale │
└─────────────────────────────────────────────────────────────┘
Locale Detection (Priority Order)
function detectLocale(c: Context): string {
// 1. Query parameter (?lang=ms)
const queryLang = c.req.query('lang');
if (queryLang && isSupported(queryLang)) return queryLang;
// 2. Accept-Language header
const acceptLang = c.req.header('Accept-Language');
const matched = matchLocale(acceptLang);
if (matched) return matched;
// 3. Cookie (saved preference)
const cookieLang = getCookie(c, 'locale');
if (cookieLang && isSupported(cookieLang)) return cookieLang;
// 4. Default
return DEFAULT_LOCALE; // 'en'
}
Translation Files
infrastructure/i18n/
├── locales/
│ ├── en.json
│ ├── ms.json
│ └── zh.json
└── index.ts
Example: en.json
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"loading": "Loading...",
"error": "An error occurred"
},
"auth": {
"login": {
"title": "Welcome back",
"email": "Email address",
"password": "Password",
"submit": "Sign in",
"forgot": "Forgot password?"
},
"errors": {
"invalidCredentials": "Invalid email or password",
"sessionExpired": "Your session has expired. Please log in again."
}
},
"users": {
"created": "User created successfully",
"notFound": "User not found",
"emailExists": "An account with this email already exists"
},
"errors": {
"validation": {
"required": "{{field}} is required",
"email": "Please enter a valid email address",
"minLength": "{{field}} must be at least {{min}} characters"
}
}
}
Example: ms.json
{
"common": {
"save": "Simpan",
"cancel": "Batal",
"delete": "Padam",
"edit": "Edit",
"loading": "Memuatkan...",
"error": "Ralat berlaku"
},
"auth": {
"login": {
"title": "Selamat回来",
"email": "Alamat e-mel",
"password": "Kata laluan",
"submit": "Daftar masuk",
"forgot": "Lupa kata laluan?"
},
"errors": {
"invalidCredentials": "E-mel atau kata laluan tidak sah",
"sessionExpired": "Sesi anda telah tamat. Sila daftar masuk semula."
}
},
"users": {
"created": "Pengguna berjaya dibuat",
"notFound": "Pengguna tidak dijumpai",
"emailExists": "Akaun dengan e-mel ini sudah wujud"
},
"errors": {
"validation": {
"required": "{{field}} diperlukan",
"email": "Sila masukkan alamat e-mel yang sah",
"minLength": "{{field}} mestilah sekurang-kurangnya {{min}} aksara"
}
}
}
Usage
In Use Cases
export class CreateUserUseCase {
constructor(
private i18n: I18nService,
) {}
async execute(input: CreateUserInput): Promise<Result> {
if (await this.userRepo.emailExists(input.email)) {
// Return localized error
return Err({
code: 'EMAIL_EXISTS',
message: this.i18n.t('users.emailExists'),
});
}
// ...
}
}
In Middleware
// Attach translator to context
app.use('*', async (c, next) => {
const locale = detectLocale(c);
const t = await i18n.createTranslator(locale);
c.set('t', t);
await next();
});
In Response Formatting
// Global response formatter
app.use('*', async (c, next) => {
await next();
const t = c.get('t');
const body = c.get('body');
// Translate error messages in response
if (body?.error?.code) {
body.error.message = t(`errors.${body.error.code}`);
}
});
Interpolation
Support variable substitution:// Template: "{{field}} is required"
// Input: { field: "Email" }
// Output: "Email is required"
// Multiple variables
// Template: "{{name}} must be at least {{min}} characters"
// Input: { name: "Password", min: 8 }
// Output: "Password must be at least 8 characters"
Pluralization
{
"messages": {
"unread": {
"zero": "No unread messages",
"one": "{{count}} unread message",
"other": "{{count}} unread messages"
}
}
}
// Usage
t('messages.unread', { count: messages.length });
Fallback Chain
Requested: "zh-CN"
│
▼
┌─────────┐
│ zh-CN │ (exact match)
└────┬────┘
│
▼ (not found)
┌─────────┐
│ zh │ (language only)
└────┬────┘
│
▼ (not found)
┌─────────┐
│ en │ (default)
└─────────┘
Configuration
DEFAULT_LOCALE=en
SUPPORTED_LOCALES=en,ms,zh
Setting Locale
Query Parameter
POST /api/v1/users?lang=ms
Accept-Language Header
Accept-Language: ms, en;q=0.9
Cookie
// Set locale preference
setCookie(c, 'locale', 'ms', {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365, // 1 year
});
Best Practices
- Use translation keys — Not raw strings in code
- Group by feature —
auth.login.title, notloginTitle - Include variables — Avoid string concatenation
- Plan fallback — Always have a complete default locale (en)
- Don’t over-translate — Technical errors can stay in English