Rails I18n Expert
Internationalize and localize Rails applications using the I18n framework. Every user-facing string belongs in a locale file — never hardcode.
Philosophy
Core Principles:
- Every string in locale files — No hardcoded user-facing text in views, controllers, mailers, or models
- Lazy lookups everywhere — Use
.titlenotbooks.index.titlein views/controllers - Organize by feature, not language — Split locale files by domain (models, views, defaults), not one giant file
- YAML is king — Use
.ymlfiles unless you need Ruby lambdas for date formats - Fail loud in dev/test — Set
raise_on_missing_translations = trueso you catch missing keys early
When To Use This Skill
- Adding I18n support to an existing Rails app
- Creating or editing YAML locale files
- Using
t()/I18n.t()andl()/I18n.l()helpers - Setting up locale switching (URL, subdomain, header, user preference)
- Translating Active Record model names, attributes, and error messages
- Localizing dates, times, numbers, and currency
- Setting up pluralization rules for non-English locales
- Organizing locale files in large applications
- Configuring fallbacks and available locales
Instructions
Step 1: Check Existing I18n Setup
Inspect the project's current I18n configuration first — mismatched conventions cause key lookup failures:
# Check existing locale files
find config/locales -name "*.yml" -o -name "*.rb" | sort
# Check I18n config
grep -r "i18n" config/application.rb config/initializers/ config/environments/
# Check available locales
grep -r "available_locales" config/
# Check for existing translation usage
rg "I18n\.t\b|\ t[\(\ ][\'\"\.]" --type ruby --type erb -l
# Check for hardcoded strings in views (potential I18n candidates)
rg -l ">[A-Z][a-z]+" app/views/ --type erb
Match existing conventions. If the project uses flat keys, don't introduce nested. If they organize by feature, follow that.
Step 2: Configure I18n Properly
Minimum viable config in config/application.rb:
# config/application.rb
config.i18n.available_locales = [:en, :es, :fr]
config.i18n.default_locale = :en
config.i18n.fallbacks = true # Falls back to default_locale
config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}")]
In test/development — catch missing translations:
# config/environments/test.rb
config.i18n.raise_on_missing_translations = true
# config/environments/development.rb
config.i18n.raise_on_missing_translations = true
Set available_locales — without it, any locale string is accepted, which opens the door to file-system traversal attacks and unexpected fallback behavior.
Step 3: Use Translation Helpers Correctly
Basic Lookups
# In views (translate helper auto-available)
t("hello") # Simple key
t("messages.welcome") # Nested key
t(:welcome, scope: :messages) # Same thing, symbol + scope
# In controllers/models/services
I18n.t("messages.welcome")
# With default fallback
t("missing.key", default: "Fallback text")
t("missing.key", default: [:other_key, "Final fallback"])
Lazy Lookups (PREFER THESE)
Lazy lookups auto-scope based on the view path or controller action:
# config/locales/en.yml
en:
books:
index:
title: "All Books"
empty: "No books found"
show:
title: "Book Details"
create:
success: "Book created!"
failure: "Could not create book."
<%# app/views/books/index.html.erb %>
<h1><%= t(".title") %></h1> <%# Resolves to books.index.title %>
<p><%= t(".empty") %></p> <%# Resolves to books.index.empty %>
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def create
if @book.save
redirect_to @book, notice: t(".success") # books.create.success
else
flash.now[:alert] = t(".failure") # books.create.failure
render :new, status: :unprocessable_entity
end
end
end
Prefer lazy lookups (.key) in views and controllers — they keep translation keys DRY and tied to the file structure. Only use full paths when referencing shared/global keys.
Interpolation
en:
greeting: "Hello, %{name}!"
item_count: "You have %{count} items in %{location}"
t("greeting", name: current_user.name)
t("item_count", count: 5, location: "your cart")
Don't use scope or default as interpolation variable names — they're reserved by I18n and raise I18n::ReservedInterpolationKey.
Pluralization
en:
notifications:
zero: "No notifications" # optional for English
one: "1 notification"
other: "%{count} notifications"
t("notifications", count: 0) # => "No notifications"
t("notifications", count: 1) # => "1 notification"
t("notifications", count: 42) # => "42 notifications"
The :count variable is magic — it selects the plural form AND interpolates into the string.
English needs only one and other. Other languages need different forms:
- Arabic:
zero,one,two,few,many,other - Russian:
one,few,many,other - Japanese:
otheronly
Use the rails-i18n gem for locale-specific pluralization rules.
HTML-Safe Translations
Keys ending in _html or named html are automatically marked HTML-safe in views:
en:
welcome_html: "<strong>Welcome</strong> to %{app_name}"
help:
html: "Need <em>help</em>? <a href='%{url}'>Contact us</a>"
<%= t("welcome_html", app_name: "MyApp") %> <%# HTML not escaped %>
Interpolated values ARE still escaped (safe against XSS). Only use _html keys when the translation itself contains markup.
Step 4: Translate Active Record Models
Model Names and Attributes
en:
activerecord:
models:
user:
one: "User"
other: "Users"
admin/post: "Admin Post" # Namespaced model
attributes:
user:
email: "Email address"
first_name: "First name"
user/role: # Nested attribute
admin: "Administrator"
User.model_name.human # => "User"
User.model_name.human(count: 2) # => "Users"
User.human_attribute_name(:email) # => "Email address"
Validation Error Messages
Error messages look up in this order (first match wins):
activerecord.errors.models.MODEL.attributes.ATTRIBUTE.ERROR
activerecord.errors.models.MODEL.ERROR
activerecord.errors.messages.ERROR
errors.attributes.ATTRIBUTE.ERROR
errors.messages.ERROR
en:
activerecord:
errors:
models:
user:
attributes:
email:
blank: "is required — we need this to contact you"
taken: "is already registered"
name:
too_short: "must be at least %{count} characters"
# Applies to all attributes on User:
invalid: "has a problem"
# Applies to all models:
messages:
blank: "can't be empty"
# Global fallback for all models:
errors:
format: "%{attribute}: %{message}" # Customize full_message format
messages:
blank: "is required"
Available interpolation variables in error messages: model, attribute, value, count.
Step 5: Localize Dates, Times, and Numbers
Date/Time Formatting
en:
date:
formats:
default: "%Y-%m-%d"
short: "%b %d"
long: "%B %d, %Y"
time:
formats:
default: "%a, %d %b %Y %H:%M:%S %z"
short: "%d %b %H:%M"
long: "%B %d, %Y %H:%M"
l(Date.today) # Default format
l(Date.today, format: :short) # Short format
l(Time.current, format: :long) # Long format
Use l() for dates/times — strftime ignores the current locale, so dates won't format correctly for non-English users.
Number Formatting
Number helpers (number_to_currency, number_with_delimiter, etc.) read from locale files:
en:
number:
format:
separator: "."
delimiter: ","
precision: 3
currency:
format:
unit: "$"
format: "%u%n" # $1,000.00
separator: "."
delimiter: ","
precision: 2
es:
number:
currency:
format:
unit: "€"
format: "%n %u" # 1.000,00 €
separator: ","
delimiter: "."
Step 6: Set Locale Per Request
Use around_action with I18n.with_locale — setting I18n.locale = directly leaks across requests in threaded servers (Puma), causing users to see other users' locales.
From URL Path (Recommended)
# config/routes.rb
scope "(:locale)", locale: /en|es|fr/ do
resources :books
# ... all routes
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
around_action :switch_locale
def switch_locale(&action)
locale = params[:locale] || I18n.default_locale
I18n.with_locale(locale, &action)
end
def default_url_options
{ locale: I18n.locale }
end
end
Combined Priority Chain
def resolve_locale
params[:locale].presence ||
current_user&.locale.presence ||
request.env["HTTP_ACCEPT_LANGUAGE"]&.scan(/^[a-z]{2}/)&.first&.then { |l|
l.to_sym if I18n.available_locales.include?(l.to_sym)
} ||
I18n.default_locale
end
Step 7: Organize Locale Files
Small apps: one file per locale (config/locales/en.yml, es.yml).
Medium/large apps — split by concern:
config/locales/
defaults/en.yml # Date, time, number formats
models/en.yml # AR model names, attributes, errors
views/en.yml # View translations (lazy lookup keys)
mailers/en.yml # Mailer subjects and content
Load nested directories: config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}")]
YAML rules: Top-level key = locale. Keys are snake_case. Quote 'true'/'false'/'yes'/'no'/'on'/'off' as keys (YAML parses them as booleans otherwise). Keep nesting ≤ 4 levels.
Step 8: Action Mailer Translations
Mailer subjects auto-resolve from mailer_scope.action_name.subject:
en:
user_mailer:
welcome:
subject: "Welcome to %{app_name}!"
class UserMailer < ApplicationMailer
def welcome(user)
mail(to: user.email) # Subject auto-resolved
# Or with interpolation: mail(to: user.email, subject: default_i18n_subject(app_name: "MyApp"))
end
end
Step 9: Fallbacks
# config/application.rb
config.i18n.fallbacks = true # Falls back to default_locale
config.i18n.fallbacks = { es: :en, fr: :en } # Or specific chains
In development/test, keep fallbacks OFF and raise_on_missing_translations = true.
Common Agent Mistakes
- Hardcoding strings —
"Record saved"instead oft(".success") - Wrong YAML nesting — Forgetting locale key at top level, wrong indentation
- Not using lazy lookups —
t("users.show.title")instead oft(".title")in views/controllers - Forgetting
available_locales— Without it, arbitrary locale strings are accepted (security risk) - Using
I18n.locale =— Leaks across requests in threaded servers; useI18n.with_locale - Pluralization without
count— Returns raw hash instead of string - Missing
_htmlsuffix — HTML in translations gets escaped without it - Not quoting YAML booleans —
true,false,yes,noare parsed as booleans; quote them when used as keys - Forgetting to restart server — New locale files require restart to load
Quick Reference
Translation Lookup Methods
| Context | Method | Lazy Lookup |
|---------|--------|-------------|
| Views | t(".key") or t("full.key") | ✅ Yes |
| Controllers | t(".key") or I18n.t("full.key") | ✅ Yes |
| Models | I18n.t("full.key") | ❌ No |
| Mailers | I18n.t("full.key") | ❌ No |
| Services/Jobs | I18n.t("full.key") | ❌ No |
Essential Locale File Structure
en:
# View translations (lazy lookups)
controller_name:
action_name:
key: "value"
# Shared/global
shared:
save: "Save"
cancel: "Cancel"
# Active Record
activerecord:
models:
model_name: { one: "Singular", other: "Plural" }
attributes:
model_name:
attribute: "Label"
errors:
models:
model_name:
attributes:
attribute:
error_type: "message"
# Formats (or use rails-i18n gem)
date:
formats: { default: "%Y-%m-%d", short: "%b %d", long: "%B %d, %Y" }
time:
formats: { default: "%Y-%m-%d %H:%M" }
number:
currency:
format: { unit: "$" }
# Mailers
mailer_name:
action_name:
subject: "Subject line"
New Locale Checklist
- [ ] Add to
available_locales - [ ] Create locale files mirroring existing structure
- [ ] Add
rails-i18ngem for date/time/number defaults + pluralization rules - [ ] Test with
raise_on_missing_translations = true - [ ] Add locale switcher UI + update
default_url_optionsif URL-based
For detailed patterns, examples, and edge cases, see the references/ directory:
references/lookups.md— Translation lookup methods, interpolation, lazy lookupsreferences/locale-files.md— YAML patterns, file organization, date/time/number localization, custom backendsreferences/pluralization.md— Pluralization rules by languagereferences/model-translations.md— Active Record model/attribute/error translationsreferences/locale-switching.md— Locale switching strategies and fallback configurationreferences/testing.md— Testing I18n, common gems (rails-i18n, i18n-tasks, mobility)