Internationalization (i18n)
This chapter covers how CARE localizes UI text: frontend and backend patterns, and how to add a new language.
Where files live
All translation keys live under utils/modules/i18n/:
en/*.json,de/*.json— key catalogs per UI language (one file = one namespace, e.g.users.json)en/index.js,de/index.js— import JSON files and bundle namespaces for the frontendi18n-bundles.js— bundlesen/denamespaces forvue-i18n
Related code:
frontend/src/assets/utils.js—resolveApiMessage,translateMaybeKey,formatLocalized*frontend/src/assets/locale.js— language switcher,app.localebackend/utils/i18n.js—translateMaybeKey,hasKey(backend runtime and migrations)
Translation guidelines
Do not translate
Table content — study titles, document names, workflow names, etc.
Server logs — dashboard log table and log files are always English (see Backend English output (translateMaybeKey) below)
Developer
console.log/ debug outputTemplate body content — multi-language content, not UI strings
How to translate
┌─────────────────────────┐
│ What do you │
│ need? │
└───────────┬─────────────┘
┌─────────────────┴─────────────────┐
│ │
New UI language UI text
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────┐
│ 5. Adding a new UI │ │ 1. Adding a key │
│ language │ │ & │
│ │ │ 2. Adding a │
└──────────┬───────────┘ │ namespace │
│ └────────┬─────────┘
│ │
│ ┌──────────────────┘
│ ▼
│ ┌─────────────────────────┐
│ │ Frontend or │
│ │ backend? │
│ └───────────┬─────────────┘
│ ┌───────┴───────┐
│ ▼ ▼
│ 3. Frontend 4. Backend
│ │ │
└─────────┴───────┬───────┘
▼
┌─────────────────────────┐
│ 6. Test it out │
└─────────────────────────┘
Sections in this chapter :
*.json namespace file1. Adding a key
Note
Need a new JSON file for a dashboard or feature? See 2. Adding a new namespace.
Example: you have a short hint for users in Users.vue — hardcoded English in a <p>:
<p>Manage team members from this page.</p>
To show it in the user’s UI language, replace the literal text with $t(...). That requires a key in the
locale JSON files first.
1. Choose a key name
Use dot notation: namespace.rest.of.key. The first segment is the JSON filename (namespace), e.g.
users.labels.manageHint → file users.json, nested path labels.manageHint.
2. Add the key in every active locale
utils/modules/i18n/en/users.json:
{
"labels": {
"manageHint": "Manage team members from this page."
}
}
utils/modules/i18n/de/users.json — same key, translated value:
{
"labels": {
"manageHint": "Verwalten Sie Teammitglieder auf dieser Seite."
}
}
Repeat for every locale folder (en/, de/, …). Keys must match across locales; only the text
values differ.
3. Use the key in the template
<p>{{ $t('users.labels.manageHint') }}</p>
When the user switches language in Preferences, the paragraph updates automatically.
More frontend patterns (placeholders, toasts, table headers, …): 3. Frontend. Backend errors and logging use keys too: 4. Backend.
Key layout inside a JSON file
Organize keys by screen or feature. Group related strings under clear subsections:
{
"workflows": {
"title": "Workflows",
"labels": {
"addWorkflow": "Add Workflow"
},
"errors": {
"importFailed": "Import failed"
}
}
}
Useful subsections:
title— page or modal titlelabels— field labels, short UI phrases, hintserrors— validation or action errors for that screencolumns— table header labelstoasts— toast title/message pairsmessages— confirm dialogs, longer text
Reuse common for words that appear everywhere. Before adding a new key, check
utils/modules/i18n/en/common.json — shared labels such as id, email, save, cancel.
Backend error keys usually go in errors.json (see 4. Backend).
2. Adding a new namespace
Each *.json file in a locale folder is one namespace. The filename (without .json) becomes the
first part of every key inside it.
Example: reports.json with "title": "Reports" → use $t('reports.title').
1. Create the file in every active locale (same keys, translated values):
utils/modules/i18n/en/reports.json
utils/modules/i18n/de/reports.json
{
"title": "Reports",
"labels": {
"export": "Export"
}
}
2. Register in index.js for each locale (en/index.js, de/index.js, …) — required for
the frontend:
import reports from './reports.json'
export default {
// ...existing namespaces...
reports,
}
3. Use the keys in Vue components:
<h1>{{ $t('reports.title') }}</h1>
3. Frontend
Common patterns — before (hardcoded English) and after (i18n). Add keys first as in 1. Adding a key.
Static text in a template ($t)
<!-- Before -->
<p>Manage team members from this page.</p>
<!-- After -->
<p>{{ $t('users.labels.manageHint') }}</p>
Text with a placeholder
<!-- Before -->
<p>Hello, {{ userName }}!</p>
<!-- After -->
<p>{{ $t('greeting.hello', { name: userName }) }}</p>
<!-- JSON: "hello": "Hello, {name}!" -->
Lists of strings ($tm)
When a key holds an array of messages (e.g. rotating status text), use $tm:
{
"loading": {
"messages": [
"Thinking through your request...",
"Almost there..."
]
}
}
data() {
return {
messages: this.$tm('loading.messages'),
index: 0,
};
}
Rich text in translations
When a sentence needs inline markup (<strong>, <br>, a clickable link) or dynamic values
that must stay safe, use the vue-i18n <i18n-t>
keypath and tag
keypath— the translation key (same string you would pass to$t('…')).tag— the HTML element that wraps the finished sentence.i18n-tdoes not render a bare text node. It builds the full phrase (text + slots) and wraps it in one element — the one named bytag:tag="p"paragraph wrapper →
<p>…</p>tag="span"inline wrapper →
<span>…</span>tag="div"block wrapper →
<div>…</div>Pick the tag for layout/semantics (paragraph, inline hint, block summary).
How it fits together
1. Locale — one key, placeholder {0} where the slot goes:
{
"csvTemplateHint": "Please check the format or {0} here.",
"downloadTemplate": "download the template"
}
2. Vue — keypath loads the key; tag="p" wraps the output; the <a> fills {0}:
<i18n-t keypath="dashboard.users.csvTemplateHint" tag="p">
<a class="template-link" @click="downloadTemplateCSV">
{{ $t('dashboard.users.downloadTemplate') }}
</a>
</i18n-t>
3. Result in the browser:
<p>
Please check the format or
<a class="template-link">download the template</a>
here.
</p>
Named slots
Use when placeholders in the JSON have names ({newCount}, {br}, {dupCount}). Each name maps
to a <template #name> block with the same name (without # in the JSON).
JSON:
{
"bulkImportConfirm": "Are you sure you want to bulk create {newCount} users {br} and overwrite {dupCount} users?"
}
Vue — slot name newCount fills {newCount}, br fills {br}, etc.:
<i18n-t keypath="dashboard.users.bulkImportConfirm" tag="p">
<template #newCount>
<strong>{{ userCount.new }}</strong>
</template>
<template #br>
<br />
</template>
<template #dupCount>
<strong>{{ userCount.duplicate }}</strong>
</template>
</i18n-t>
Rendered shape:
<p>
Are you sure you want to bulk create <strong>3</strong> users
<br />
and overwrite <strong>1</strong> users?
</p>
Indexed slots ({0}, {1}, …)
Use when the JSON uses numeric placeholders. They refer to default slot children in order — the
first child replaces {0}, the second replaces {1}, and so on. You do not write #0 unless you
choose explicit <template #0> (optional); a plain child element is enough for {0}.
Single slot (most common) — one link in the middle of a sentence:
JSON:
{
"csvTemplateHint": "Please check the format or {0} here.",
"downloadTemplate": "download the template"
}
Vue — the lone <a> is child #0 → fills {0}:
<i18n-t keypath="dashboard.users.csvTemplateHint" tag="p">
<a class="template-link" @click="downloadTemplateCSV">
{{ $t('dashboard.users.downloadTemplate') }}
</a>
</i18n-t>
Rendered:
<p>
Please check the format or
<a class="template-link">download the template</a>
here.
</p>
Two slots — same idea with {0} and {1}:
JSON:
{
"openDocs": "Read the {0} or watch the {1}."
}
Vue — first child → {0}, second child → {1}:
<i18n-t keypath="help.openDocs" tag="p">
<a href="/docs">documentation</a>
<a href="/tutorial">tutorial video</a>
</i18n-t>
Rendered:
<p>
Read the <a href="/docs">documentation</a> or watch the <a href="/tutorial">tutorial video</a>.
</p>
Named vs indexed — quick pick:
JSON placeholder |
Vue slot |
Typical use |
|---|---|---|
|
|
Many mixed parts; clear names for translators |
|
1st / 2nd default child (or |
One or two inline links in a short hint |
HTML inside a translation (v-html)
Use v-html only when i18n-t is impractical and the string is fully trusted — markup comes
from your locale JSON, and interpolated values are not raw user input, API text, or database names.
If a phrase needs both markup and dynamic parts, prefer Rich text in translations above.
{
"declinedSharingWarning": "You have selected students who <strong>didn't accept data sharing</strong>."
}
<span v-html="$t('dashboard.projects.export.declinedSharingWarning')" />
Danger
Use v-html only for trusted translation strings from your locale JSON — never for raw user
input, API data, or database values.
Socket/API errors (resolveApiMessage)
File: frontend/src/assets/utils.js
Use for every toast, modal, or inline message that comes from a socket callback or HTTP response.
import { resolveApiMessage } from "@/assets/utils";
// Before — shows English from the server, ignores user locale:
this.$socket.emit("userCreate", payload, (response) => {
if (!response.success) {
this.eventBus.emit("toast", {
title: "Error",
message: response.message,
variant: "danger",
});
}
});
// After — translates response.key (+ params) in the user's locale:
this.$socket.emit("userCreate", payload, (response) => {
if (!response.success) {
this.eventBus.emit("toast", {
title: this.$t("errors.users.userCreationFailed"),
message: resolveApiMessage(response),
variant: "danger",
});
}
});
resolveApiMessage checks response.key first, then falls back to response.message (plain text or
a key string). Optional second argument — fallback key if the response is empty:
resolveApiMessage(response, "errors.server.unknownError");
DB values that may be a key or plain text (translateMaybeKey)
File: frontend/src/assets/utils.js
Some values from the database are stored as an i18n key (e.g. from a migration), others as plain text
the user typed (e.g. a custom workflow name). Use translateMaybeKey when you do not know which case you
have:
Known key → translated with
$tNot a key → shown as-is
1. Import from @/assets/utils:
import { translateMaybeKey } from "@/assets/utils";
2. Expose in the component (so the template can call it):
export default {
methods: {
translateMaybeKey,
},
};
3. Use in the template or script:
<p>{{ translateMaybeKey(workflow.name) }}</p>
computed: {
displayName() {
return translateMaybeKey(this.workflow.name);
},
},
Example values for workflow.name:
workflow.dashboard.title → translated (key in JSON)
My custom workflow → shown unchanged (user text)
Table columns
Define columns in a computed property so headers update when the user switches language.
// Before — headers stay English after locale switch:
data() {
return {
columns: [
{ name: "ID", key: "id", sortable: true },
{ name: "Email", key: "email" },
{ name: "Roles", key: "roles" },
],
};
}
// After — headers follow the active locale:
computed: {
columns() {
return [
{ name: this.$t("common.id"), key: "id", sortable: true },
{ name: this.$t("common.email"), key: "email" },
{ name: this.$t("users.columns.roles"), key: "roles" },
];
},
},
Prefer common.* for generic column names; use feature keys (e.g. users.columns.*) when the label
is specific to that screen.
Locale helpers
File: frontend/src/assets/locale.js
Export / constant |
Purpose |
|---|---|
|
Languages shown in the language switcher |
|
DB setting / user_setting key |
|
Read locale from merged |
|
Browser storage |
|
Set active |
Date and time formatting (formatLocalized*)
File: frontend/src/assets/utils.js
Format dates and times in the user’s active UI locale picked in Preferences.
Function |
Output |
|---|---|
|
Date only (e.g. |
|
Time only (e.g. |
|
Date and time together |
How to use
1. Import from @/assets/utils:
import { formatLocalizedDate, formatLocalizedDateTime } from "@/assets/utils";
2. Expose in the component:
export default {
methods: {
formatLocalizedDate,
formatLocalizedDateTime,
},
};
3. Use in the template:
<!-- Before — follows OS locale, not app locale -->
<span>{{ new Date(user.lastLoginAt).toLocaleDateString() }}</span>
<!-- After — follows active UI locale -->
<span>{{ formatLocalizedDate(user.lastLoginAt) }}</span>
Format while preparing table rows (common pattern — no need to expose in methods):
import { formatLocalizedDate } from "@/assets/utils";
computed: {
usersForTable() {
return this.users.map((user) => ({
...user,
lastLoginAt: user.lastLoginAt ? formatLocalizedDate(user.lastLoginAt) : "-",
}));
},
},
Optional Intl.DateTimeFormat options as the second argument (options) — same shape as for
Date.prototype.toLocaleDateString, toLocaleTimeString, and toLocaleString:
formatLocalizedDate(session.start, { year: "numeric", month: "short", day: "numeric" });
formatLocalizedTime(edit.createdAt, { hour: "2-digit", minute: "2-digit" });
4. Backend
Throwing errors
Backend responses support two formats — both are valid:
Preferred:
{ success: false, key: "errors.auth.invalidCredentials", params: { ... } }Legacy:
{ success: false, message: "Plain text or an i18n key string" }
The frontend resolveApiMessage handles both. New code should prefer key + optional params.
User-facing error in a handler
const TranslatableError = require("../utils/TranslatableError");
// Before — hardcoded English, wrong language in the UI:
if (!user) {
throw new Error("User not found.");
}
// After — key + params; frontend shows the translated message:
if (!user) {
throw new TranslatableError("errors.users.notFound", { id: userId });
}
// JSON (errors.json): "notFound": "User with id {id} was not found."
TranslatableError
File: backend/utils/TranslatableError.js
When something goes wrong in a way the user should see (wrong password, missing permission, validation failed), throw a TranslatableError instead of a hardcoded English sentence.
Goal: pass an i18n key (and optional params / code) from the backend to the frontend. The
browser translates with resolveApiMessage. Logging and legacy English message in socket callbacks are
handled in Socket.js — see Backend English output (translateMaybeKey).
Why a dedicated class:
Structured payload — carries the i18n key, optional params for placeholders (e.g.
{ validationLimit: 10 }), and an optional machine-readable **code``. A plainErroronly hasmessage; boltingparams/codeonto arbitrary errors is fragile in catch handlers.Security boundary — in
Socket.js, only errors with a known i18n key (TranslatableError,generateError, or legacyError("errors.*")) become{ key, params, code }in the client callback. Anything else (DB failures, bugs, plain EnglishError) is logged in full and mapped toerrors.server.unexpectedErrorfor the user.
Rule of thumb: user-facing → TranslatableError or generateError; internal / unexpected →
plain Error (not for UI text). Patterns:
Pattern |
When to use |
|---|---|
|
error, key only |
|
error with |
|
key + params + machine-readable |
|
machine |
|
Internal only — not for user-facing UI text |
Files: backend/utils/TranslatableError.js, backend/utils/generic.js (generateError)
Simple examples:
// 1. Key only
throw new TranslatableError("errors.auth.invalidCredentials");
// 2. Key + params
throw new TranslatableError("errors.users.notFound", { id: userId });
// 3. Key + params + code (frontend may check response.code)
throw new TranslatableError(
"errors.assignment.unableToAssignEnoughDocuments",
{ roleName: "Reviewer", count: 5 },
"ASSIGNMENT_FAILED"
);
// 4. Code + key, no params
throw generateError("DOCUMENT_NOT_FOUND", "errors.documents.doesNotExistOrDeleted");
Socket callback payload
Most socket handlers are registered with createSocket in Socket.js. On success, the callback
receives { success: true, data: result }. On failure, prefer throwing an error — the shared
catch block builds the callback for you:
// Handler (backend) — throw; do not call callback yourself
if (!user) {
throw new TranslatableError("errors.users.notFound", { id: userId });
}
// What the frontend receives in (response) => { ... }:
{
success: false,
key: "errors.users.notFound",
params: { id: userId },
message: "User with id 123 was not found.", // English fallback (translateMaybeKey)
code: "..." // optional, when set on the error
}
The frontend should display errors with resolveApiMessage(response) — it translates key + params
to the user’s locale. The message field is a legacy English fallback; new UI code should not read it
directly.
Manual responses (validation results, per-row errors in data, direct socket.emit) may still use
the legacy shape without a separate key field — put the i18n key in message instead:
// Legacy — still works; resolveApiMessage treats message as a key when it exists in JSON
{ success: false, message: "errors.validation.additionalFilesNotAllowed", params: { files: "foo.txt" } }
// Preferred when you build the object yourself — same as the hub format
{ success: false, key: "errors.validation.additionalFilesNotAllowed", params: { files: "foo.txt" } }
Frontend:
this.$socket.emit("userCreate", payload, (response) => {
if (!response.success) {
this.eventBus.emit("toast", {
title: this.$t("errors.users.userCreationFailed"),
message: resolveApiMessage(response),
variant: "danger",
});
}
});
Backend English output (translateMaybeKey)
File: backend/utils/i18n.js
Same idea as frontend translateMaybeKey: if the string is a known i18n key,
translate it; otherwise return it unchanged. On the backend this always uses the English catalog — not the
user’s locale. Use the same helper for logs, socket fallbacks, and migrations (down steps that
restore English text when rolling back rows that store i18n keys).
Anti-pattern — do not translate twice for logs
SQLTransport already calls translateMaybeKey on every logger.* message. Do not call
translateMaybeKey before logger.error — the same string would be processed twice (harmless but redundant).
// Bad — translated before logger, then SQLTransport translates again
this.logger.error(translateMaybeKey(key, params));
// Good — pass the key; SQLTransport translates once (use i18nParams for placeholders)
this.logger.error(key, { i18nParams: params });
Call sites often pass either an i18n key or plain English:
Dashboard log table —
logger.error("errors.users.notFound")orlogger.error(key, { i18nParams }).SQLTransportappliestranslateMaybeKeyonce before insert.Socket error responses — log the key (
logger.error(key, { i18nParams: params })); callback legacymessageusestranslateMaybeKey(key, params).Migrations — when
upstores i18n keys in the database (e.g.placeholderLabel),downcallstranslateMaybeKeyto write back the English source string. If the key is missing fromenJSON, the key string is returned unchanged (safe for rollback).
const { translateMaybeKey } = require("../utils/i18n");
const key = "errors.users.importFailed";
// Logger — pass key or plain English; SQLTransport translates once
this.logger.error(key);
this.logger.error("errors.auth.passwordResetRateLimited", { i18nParams: { minutes: 5 } });
this.logger.error("Import finished with warnings");
// Socket catch (simplified)
this.logger.error(key, { i18nParams: params });
callback({ success: false, key, params, message: translateMaybeKey(key, params) });
Migration example:
const { translateMaybeKey } = require("../../utils/i18n");
module.exports = {
async up(queryInterface) {
await queryInterface.bulkUpdate(
"placeholder",
{ placeholderLabel: "templates.placeholders.labels.emailGeneral.username" },
{ type: 1, placeholderKey: "username" },
);
// ...more rows
},
async down(queryInterface) {
const label = translateMaybeKey("templates.placeholders.labels.emailGeneral.username");
await queryInterface.bulkUpdate(
"placeholder",
{ placeholderLabel: label },
{ type: 1, placeholderKey: "username" },
);
// ...more rows
},
};
5. Adding a new UI language
Example: adding French (fr) as a third UI language.
1. Copy and translate JSON catalogs
Duplicate utils/modules/i18n/en/ to utils/modules/i18n/fr/. Translate every value in every JSON
file. Keys must stay identical — only the text changes.
utils/modules/i18n/en/common.json → utils/modules/i18n/fr/common.json
utils/modules/i18n/en/errors.json → utils/modules/i18n/fr/errors.json
... (all other JSON files)
2. Register the locale in i18n-bundles.js
import en from './en/index.js'
import de from './de/index.js'
import fr from './fr/index.js'
const i18nBundles = { en, de, fr }
export { en, de, fr, i18nBundles }
export default i18nBundles
The fr/index.js file comes from the copy in step 1 (same imports as en/index.js, pointing at
fr/*.json).
3. Add French to the language switcher in frontend/src/assets/locale.js:
export const SUPPORTED_LOCALES = [
{ code: "de", name: "Deutsch", flag: "🇩🇪" },
{ code: "en", name: "English", flag: "🇬🇧" },
{ code: "fr", name: "Français", flag: "🇫🇷" },
];
4. Test — switch to French in Preferences and walk through the main screens. See 6. Test it out.
6. Test it out
Switch language in Preferences (or use the new locale from 5. Adding a new UI language)
Open the screen you changed and confirm labels / table headers update
Trigger a socket or API error and confirm
resolveApiMessageshows the right textIf you added backend logging or socket errors, confirm English output uses
translateMaybeKeywhere neededIf you added new keys, repeat steps 1–3 in each supported locale (
en,de, …)
7. Linter information
What is checked
Frontend (ESLint + frontend/scripts/check-i18n-keys.mjs )
Vue templates —
@intlify/vue-i18n/no-raw-textflags hardcoded English in template text.Missing keys
Hardcoded English in script — string props such as
title,message,label,textin<script>/.jsthat look like user-facing English.Unused catalog keys — keys present in JSON that are referenced on neither frontend nor backend.
Backend ( backend/scripts/check-i18n-keys.mjs )
User-facing errors —
TranslatableErrorandgenerateError(code, key): the key must exist in the catalog; leftover English in those helpers is flagged.Model / migration UI strings — props such as
label,placeholder,title,message,text(and optionnamewhere applicable): if the value looks like an i18n key, it must exist in EN; models also flag leftover English on those props.
Note
To ignore certain lines, add // i18n-lint-ignore on that line.
See also
Logging — Winston logging setup
Interface Language — end-user language settings
Email, Document, and Prompt Templates — template content languages (not UI locale)