Skip to content

Internationalization (i18n) & Localization

Overview

The i18n system has three layers: source files (JS), compiled output (JSON), and a runtime service. The cardinal rule is:

Never edit locales/*.json directly. Always edit languages/*.js source files, then run yarn lang.


The Flow

languages/en.js              (global source — GLOBAL namespace)
app/*/languages/en.js        (feature source — FEATURENAME namespace)
        ↓  yarn lang
locales/en.json              (compiled — do not edit)
        ↓  runtime
services/language.js         (i18n middleware + getLocalI18n())

Key Naming Convention

All keys follow the pattern NAMESPACE[snake_case_key]:

  • NamespaceALL_CAPS. Use the feature name (ADMIN, USER, BOOKING) or GLOBAL for shared strings.
  • Keyall_lowercase_with_underscores. No camelCase, no dashes, no mixed case.
GLOBAL[unauthorized]
GLOBAL[internal_server_error]
USER[profile_not_found]
ADMIN[invalid_login_credentials]
ORDER[payment_failed]

Source Files

Global strings — languages/en.js

Strings shared across the entire app. Use the GLOBAL namespace.

javascript
// languages/en.js
module.exports = {
  'GLOBAL[unauthorized]':          'You do not have permission to make this request.',
  'GLOBAL[internal_server_error]': 'Oops... something went wrong.',
};

Feature strings — app/<Feature>/languages/en.js

Strings scoped to one feature. Use the feature name as the namespace.

javascript
// app/Admin/languages/en.js
module.exports = {
  // V1Login
  'ADMIN[invalid_login_credentials]': 'The email and/or password you entered is incorrect.',
  'ADMIN[admin_account_inactive]':    'Admin account is inactive.',

  // V1Create
  'ADMIN[admin_already_exists]':      'Admin user already exists.',
};

Grouping by action: Organize keys with inline comments by the action that uses them. This makes it easy to find and delete keys when you remove an action.

Interpolation

Use syntax for dynamic values:

javascript
'ADMIN[reset_email_success_message]': 'An email has been sent to {{email}}. Please check your email.',

In the action:

javascript
res.__('ADMIN[reset_email_success_message]', { email: args.email });

Compiling — yarn lang

Running yarn lang invokes services/language.js compile(), which:

  1. Loads languages/en.js (global strings)
  2. Walks every app/*/languages/en.js (feature strings)
  3. Merges all keys into one flat object
  4. Writes locales/en.json
json
// locales/en.json (compiled — do not edit manually)
{
  "GLOBAL[unauthorized]": "You do not have permission to make this request.",
  "ADMIN[invalid_login_credentials]": "The email and/or password you entered is incorrect."
}

yarn test runs yarn lang automatically before Jest starts, so a missing or misnamed key fails fast before any test runs.


Runtime Service (services/language.js)

In actions (HTTP)

Locale is set automatically by middleware — you never call setLocale manually. The detection order is:

  1. req.user.locale — logged-in user's saved preference (set in middleware/auth.js)
  2. lang argument — if any request passes lang in req.args, middleware/args.js calls req.setLocale() and removes it from req.args
  3. i18n-locale cookie — persisted from a prior locale switch
  4. Default locale (en)

Use req.__('KEY') or res.__('KEY') directly in actions:

javascript
res.__('ADMIN[reset_email_success_message]', { email: args.email });

In tasks (background jobs)

Tasks run in a worker process — there is no req object. Use getLocalI18n() and set the locale manually from job.data:

javascript
const { getLocalI18n } = require('../../services/language');

// In the action — capture locale when enqueueing
Queue.add('V1SendWelcomeEmailTask', {
  userId: user.id,
  locale: req.user.locale,
});
javascript
// In the task — set locale on a fresh local instance
const i18n = getLocalI18n();
i18n.setLocale(job.data.locale || 'en');

const message = i18n.__('ADMIN[reset_email_success_message]', { email: user.email });

Why getLocalI18n() instead of a shared instance? The i18n module is stateful. Sharing one instance across concurrent jobs means one job can change the locale and bleed into another. getLocalI18n() returns a fresh object scoped to the request or job.

The yarn gen task scaffolding template includes this locale boilerplate automatically. Remove it if the task produces no user-facing strings.

In services

Same as tasks — use getLocalI18n() directly.


Key Safety

Two mechanisms prevent missing translation keys from reaching users silently:

Compile-time (validateKeys): After yarn lang compiles, it scans all JS files in app/, services/, helpers/, and middleware/ for static .__('KEY') calls and verifies every key exists in the compiled default locale. In test and production environments this throws and aborts the process. In development it prints a yellow warning.

Runtime (missingKeyFn): If a key is missing at runtime, the i18n middleware calls missingKeyFn. In test and production this throws immediately. In development it logs a red error and returns the raw key string so the app does not crash.

Together these mean a missing translation key will fail loudly at compile time (during yarn lang) and again at runtime if one slips through.

Common gotcha: If the test suite won't start and shows an error about a missing i18n key, you used a .__('KEY') that is not defined. Add it to languages/*.js and run yarn lang.


Adding a New Translation Key

  1. Add the key/value pair to the appropriate languages/*.js file (global or feature)
  2. Run yarn lang to recompile
  3. Reference the key in your action with req.__('KEY') or res.__('KEY')

Use the add-locale skill for step-by-step guidance.


Adding a New Locale

  1. Create the corresponding language file in every feature folder and the global languages/ folder (e.g., languages/fr.js and app/*/languages/fr.js)
  2. Add the locale identifier (e.g., 'fr') to the LOCALES constant in helpers/constants.js
  3. Run yarn lang to compile

A helper script to scaffold a new language file across all features at once is tracked as a TODO in app/feature.js.

Use the add-locale skill — it handles this end-to-end.


Connection to Error Codes

i18n keys appear in error code definitions inside app/<Feature>/error.js. The messages array holds the translation keys:

javascript
ADMIN_BAD_REQUEST_INVALID_LOGIN_CREDENTIALS: {
  error: 'ADMIN.BAD_REQUEST_INVALID_LOGIN_CREDENTIALS',
  status: 400,
  messages: ['ADMIN[invalid_login_credentials]']
}

When you add an error code, add the corresponding translation key to languages/en.js in the same commit and run yarn lang. The add-error-code skill walks through both steps together.


Gulp Watch

The gulpfile watches languages/*.js files. When running yarn gulp, any change to a language source file automatically recompiles to locales/*.json — the same thing yarn lang does, but continuously. Useful during active development on translated copy.