Chapter 14: Localization#
1. Your App Speaks One Language#
Every string in your app is hard-coded English. A French user sees "Order Confirmation". A German user sees "Error: Invalid email". A Japanese user sees dates as "April 2, 2026" instead of "2026年4月2日".
Localization separates translatable strings from code. Add a locale file per language. Switch the active locale per request. Every t("key") call returns the right string for the current user.
2. Locale Files#
Store locale files as JSON in src/locales/. Name them by locale code.
src/locales/en.json:
{ "greeting": "Hello, %{name}!", "order": { "confirmation": "Order Confirmation", "placed": "Your order #%{number} has been placed.", "total": "Total: %{currency}%{amount}" }, "errors": { "invalid_email": "Invalid email address.", "required": "%{field} is required." }, "nav": { "home": "Home", "account": "My Account", "logout": "Log Out" }}src/locales/fr.json:
{ "greeting": "Bonjour, %{name}!", "order": { "confirmation": "Confirmation de commande", "placed": "Votre commande #%{number} a été passée.", "total": "Total: %{currency}%{amount}" }, "errors": { "invalid_email": "Adresse e-mail invalide.", "required": "%{field} est requis." }, "nav": { "home": "Accueil", "account": "Mon compte", "logout": "Se déconnecter" }}3. Basic Translation#
Tina4::Localization is a module-level singleton. Load the locale files once at startup, then translate with t:
Tina4::Localization.load # scans locales/, translations/, i18n/, src/locales/Tina4::Localization.set_locale("en")puts Tina4::Localization.t("greeting", name: "Alice")# => Hello, Alice!puts Tina4::Localization.t("order.confirmation")# => Order Confirmationputs Tina4::Localization.t("nav.logout")# => Log OutDot notation navigates nested keys. "order.confirmation" maps to locales["order"]["confirmation"].
4. Interpolation#
Use %{placeholder} in locale values. Pass named keyword arguments to t.
puts Tina4::Localization.t("order.placed", number: "1042")# => Your order #1042 has been placed.puts Tina4::Localization.t("order.total", currency: "$", amount: "79.99")# => Total: $79.99puts Tina4::Localization.t("errors.required", field: "Email")# => Email is required.Missing interpolation variables are left as-is in the output string.
5. Switching Locales#
Call set_locale to change the active locale, or pass locale: to a single t call.
Tina4::Localization.set_locale("en")puts Tina4::Localization.t("greeting", name: "Alice")# => Hello, Alice!Tina4::Localization.set_locale("fr")puts Tina4::Localization.t("greeting", name: "Alice")# => Bonjour, Alice!# Or override for a single call without changing the active locale:puts Tina4::Localization.t("greeting", locale: "fr", name: "Alice")# => Bonjour, Alice!6. Fallback Locale#
When a key is missing from the active locale, t automatically falls back to English ("en") before returning the key itself.
Tina4::Localization.set_locale("de")# "de" locale has no "nav.logout" keyputs Tina4::Localization.t("nav.logout")# => Log Out (falls back to English)# You can also supply an explicit default for a single call:puts Tina4::Localization.t("nav.missing", default: "Menu")# => MenuFallback prevents missing key errors in partially translated locales.
7. Per-Request Locale in Routes#
Read the user's preferred locale from a header, cookie, or query param and pass it to t with locale:. Passing locale: per call avoids mutating the shared active locale across concurrent requests.
# @noauthTina4::Router.get("/api/greeting") do |request, response| locale = request.headers["Accept-Language"]&.split(",")&.first&.strip || "en" locale = locale[0..1] # normalize "fr-FR" to "fr" response.json({ message: Tina4::Localization.t("greeting", locale: locale, name: "World"), locale: locale })endcurl -H "Accept-Language: fr" http://localhost:7147/api/greeting{ "message": "Bonjour, World!", "locale": "fr" }curl -H "Accept-Language: de" http://localhost:7147/api/greeting{ "message": "Hello, World!", "locale": "en" }German falls back to English because de.json does not exist.
8. Available Locales#
List all locale files present in the locales directory.
Tina4::Localization.loadputs Tina4::Localization.available_locales.inspect# => ["en", "fr"]Use this to build a language switcher UI.
# @noauthTina4::Router.get("/api/locales") do |request, response| response.json({ locales: Tina4::Localization.available_locales })end9. Locale in Templates#
Tina4 auto-wires t() as a template global, so templates call it directly. Set the active locale for the request before rendering.
Tina4::Router.get("/dashboard") do |request, response| locale = request.cookies["locale"] || "en" Tina4::Localization.set_locale(locale) response.render("dashboard.html", { user: { name: "Alice" } })endIn src/templates/dashboard.html (Frond template):
<nav> <a href="/">{{ t("nav.home") }}</a> <a href="/account">{{ t("nav.account") }}</a> <a href="/logout">{{ t("nav.logout") }}</a></nav> <h1>{{ t("greeting", name=user.name) }}</h1>10. Date and Number Formatting#
Add locale-specific formatting helpers to your locale files or handle them with Ruby's standard library.
# Format a date string for the current localedate = Time.nowformatted = case Tina4::Localization.current_locale when "en" then date.strftime("%B %-d, %Y") when "fr" then date.strftime("%-d %B %Y") when "de" then date.strftime("%-d. %B %Y") else date.strftime("%Y-%m-%d") endputs formatted# en: April 2, 2026# fr: 2 avril 2026# de: 2. April 202611. Gotchas#
1. Missing locale file returns fallback silently#
If the requested locale file does not exist and a fallback is set, the fallback runs without warning. Log the missing locale in development.
2. Nested key typo#
Tina4::Localization.t("order.confimation") (typo) returns the key string "order.confimation" unchanged. If you see keys appearing raw in the UI, check for spelling mistakes in your t() calls.
3. Reload locale files in development#
Locale file changes do not reload automatically in a running server. Restart the server or implement a dev-mode watch pattern.
4. Interpolation keys are case-sensitive#
{Name} in the locale file is not the same as name: in the t() call. Keep all interpolation placeholders lowercase.