Chapter 14: Localization#
1. Your App Speaks One Language. Your Users Speak Many.#
An e-commerce store. Half your traffic is German. Error messages in English. Product descriptions in English. Currency formatted American-style. Visitors leave.
Localization means serving content in the user's language and format. Tina4 provides an I18n class that loads JSON locale files, interpolates variables, falls back to a default locale when a key is missing, and switches locale per request.
2. Locale Files#
Locale files are JSON files stored in a locales/ directory in your project root. One file per language:
locales/ en.json de.json fr.json es.jsonlocales/en.json:
{ "welcome": "Welcome, {{name}}!", "errors": { "not_found": "The page you requested was not found.", "unauthorized": "You must be logged in to access this page.", "validation": "{{field}} is required." }, "orders": { "placed": "Your order #{{orderId}} has been placed.", "total": "Total: {{currency}}{{amount}}" }, "nav": { "home": "Home", "products": "Products", "cart": "Cart", "account": "Account" }}locales/de.json:
{ "welcome": "Willkommen, {{name}}!", "errors": { "not_found": "Die angeforderte Seite wurde nicht gefunden.", "unauthorized": "Sie müssen angemeldet sein, um auf diese Seite zuzugreifen.", "validation": "{{field}} ist erforderlich." }, "orders": { "placed": "Ihre Bestellung #{{orderId}} wurde aufgegeben.", "total": "Gesamt: {{currency}}{{amount}}" }, "nav": { "home": "Startseite", "products": "Produkte", "cart": "Warenkorb", "account": "Konto" }}3. Creating an I18n Instance#
import { I18n } from "tina4-nodejs";// Loads all JSON files from ./locales/// Default locale is "en"const i18n = new I18n({ localesPath: "./locales", defaultLocale: "en"});| Option | Default | Description |
|---|---|---|
localesPath | "./locales" | Directory containing locale JSON files |
defaultLocale | "en" | Fallback locale when a key is missing |
fallbackLocale | same as defaultLocale | Explicit fallback, if different from default |
4. Translating Keys with t()#
The t() method looks up a translation key in the current locale:
const i18n = new I18n({ localesPath: "./locales", defaultLocale: "en" });i18n.setLocale("en");console.log(i18n.t("nav.home")); // "Home"console.log(i18n.t("nav.products")); // "Products"i18n.setLocale("de");console.log(i18n.t("nav.home")); // "Startseite"console.log(i18n.t("nav.products")); // "Produkte"Keys use dot notation to access nested values. "errors.not_found" reaches errors -> not_found in the JSON file.
5. Interpolation#
Pass variables to replace {{placeholder}} tokens:
i18n.setLocale("en");console.log(i18n.t("welcome", { name: "Alice" }));// "Welcome, Alice!"console.log(i18n.t("errors.validation", { field: "Email" }));// "Email is required."console.log(i18n.t("orders.placed", { orderId: "10042" }));// "Your order #10042 has been placed."i18n.setLocale("de");console.log(i18n.t("welcome", { name: "Alice" }));// "Willkommen, Alice!"Any key not found in the variables object is left as-is in the output string.
6. Locale Switching Per Request#
Detect the user's preferred locale from the Accept-Language header, a query parameter, or a session value:
import { Router, I18n } from "tina4-nodejs";const i18n = new I18n({ localesPath: "./locales", defaultLocale: "en" });Router.get("/api/greeting", async (req, res) => { // Priority: ?lang query param > Accept-Language header > default const lang = req.query.lang ?? (req.headers["accept-language"]?.split(",")[0]?.split("-")[0]) ?? "en"; i18n.setLocale(lang); return res.json({ locale: lang, message: i18n.t("welcome", { name: req.query.name ?? "guest" }) });});curl "http://localhost:7148/api/greeting?name=Alice&lang=de"{ "locale": "de", "message": "Willkommen, Alice!"}curl "http://localhost:7148/api/greeting?name=Alice&lang=en"{ "locale": "en", "message": "Welcome, Alice!"}7. Fallback Locale#
When a key exists in the default locale but is missing from the requested locale, I18n falls back automatically rather than returning an empty string or throwing:
// locales/fr.json is missing the "orders" sectioni18n.setLocale("fr");console.log(i18n.t("orders.placed", { orderId: "202" }));// Falls back to "en": "Your order #202 has been placed."The fallback locale is configured once and applied globally. You never need to check whether a translation exists before calling t().
8. Listing Available Locales#
const locales = i18n.availableLocales();console.log(locales);// ["en", "de", "fr", "es"]Use this to build a language picker in your UI or to validate the lang parameter on requests.
9. Using I18n in Templates#
Pass the translation function to Frond templates:
import { Router, I18n } from "tina4-nodejs";const i18n = new I18n({ localesPath: "./locales", defaultLocale: "en" });Router.get("/store", async (req, res) => { const lang = req.query.lang ?? "en"; i18n.setLocale(lang as string); return res.render("store.frond", { t: (key: string, vars?: Record<string, string>) => i18n.t(key, vars), locale: lang, locales: i18n.availableLocales() });});In templates/store.frond:
<nav> <a href="/">{{t("nav.home")}}</a> <a href="/products">{{t("nav.products")}}</a> <a href="/cart">{{t("nav.cart")}}</a></nav>10. Exercise: Multilingual API Errors#
Build an API that returns validation error messages in the user's language.
Requirements#
- Create locale files for
enanddewith anerrorssection coveringrequired,too_short, andinvalid_email - Create a
POST /api/contactendpoint that validates aname(required, min 2 chars) andemail(required, valid format) field - Return error messages in the locale specified by the
langquery parameter (default:en)
Test with:#
# English errorscurl -X POST "http://localhost:7148/api/contact?lang=en" \ -H "Content-Type: application/json" \ -d '{"name": "A", "email": "not-an-email"}'# German errorscurl -X POST "http://localhost:7148/api/contact?lang=de" \ -H "Content-Type: application/json" \ -d '{"name": "A", "email": "not-an-email"}'11. Solution#
locales/en.json:
{ "errors": { "required": "{{field}} is required.", "too_short": "{{field}} must be at least {{min}} characters.", "invalid_email": "{{field}} must be a valid email address." }, "contact": { "success": "Thank you, {{name}}. We will be in touch." }}locales/de.json:
{ "errors": { "required": "{{field}} ist erforderlich.", "too_short": "{{field}} muss mindestens {{min}} Zeichen lang sein.", "invalid_email": "{{field}} muss eine gültige E-Mail-Adresse sein." }, "contact": { "success": "Danke, {{name}}. Wir werden uns bei Ihnen melden." }}src/routes/contact.ts:
import { Router, I18n } from "tina4-nodejs";const i18n = new I18n({ localesPath: "./locales", defaultLocale: "en" });const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;Router.post("/api/contact", async (req, res) => { const lang = (req.query.lang ?? "en") as string; i18n.setLocale(lang); const body = req.body; const errors: string[] = []; if (!body.name) { errors.push(i18n.t("errors.required", { field: "Name" })); } else if (String(body.name).length < 2) { errors.push(i18n.t("errors.too_short", { field: "Name", min: "2" })); } if (!body.email) { errors.push(i18n.t("errors.required", { field: "Email" })); } else if (!EMAIL_RE.test(String(body.email))) { errors.push(i18n.t("errors.invalid_email", { field: "Email" })); } if (errors.length > 0) { return res.status(422).json({ locale: lang, errors }); } return res.json({ locale: lang, message: i18n.t("contact.success", { name: body.name }) });});English error response:
{ "locale": "en", "errors": [ "Name must be at least 2 characters.", "Email must be a valid email address." ]}German error response:
{ "locale": "de", "errors": [ "Name muss mindestens 2 Zeichen lang sein.", "Email muss eine gültige E-Mail-Adresse sein." ]}12. Gotchas#
1. Locale files must be valid JSON#
A trailing comma or missing quote breaks the entire locale file silently. I18n will log a parse error and skip the file, falling back to the default locale for all keys.
Fix: Use a JSON linter or your editor's JSON validation before deploying. CI pipelines should run JSON.parse() against all locale files.
2. Missing keys return the key string#
If a key does not exist in any locale, t() returns the key itself (e.g., "orders.shipped"). This makes missing translations visible in production.
Fix: Maintain the English locale as the complete reference. Other locales can be partial -- they fall back to English. Audit periodically with a script that compares all locale files against the English baseline.
3. setLocale is not thread-safe for concurrent requests#
If multiple concurrent requests call i18n.setLocale() on the same instance, they interfere with each other.
Fix: Create a new I18n instance per request, or use i18n.translate(key, vars, locale) which accepts the locale as a parameter without mutating instance state.
4. Placeholder names are case-sensitive#
{{Name}} and {{name}} are different placeholders. A mismatch silently leaves the placeholder unreplaced.
Fix: Use consistent lowercase placeholder names in all locale files.