i18n
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)
More from thinkoodle/rails-skills
testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4form-helpers
Expert guidance for building forms in Rails 8 applications. Use when creating forms, form_with, form helpers, nested forms, select helpers, file uploads, form builders, accepts_nested_attributes_for, fields_for, collection_select, grouped_collection_select, date/time selects, checkboxes, radio buttons, rich text areas, or any form-related view code. Covers model-backed forms, URL-based forms, complex nested attributes with _destroy, custom form builders, CSRF tokens, strong parameters for nested forms, and Stimulus integration.
4action-cable
Expert guidance for implementing real-time features with Action Cable in Rails applications. Use when working with websockets, channels, subscriptions, broadcasting, live updates, push notifications, or Solid Cable. Covers connection authentication, channel creation, streaming, client-side JS, testing, and deployment. Clarifies when to use Action Cable directly vs Turbo Streams broadcasting.
4rails-components
Expert guidance for building reusable UI components in Rails using partials, CSS classes, and helpers. Use when creating components, partials, reusable UI patterns, empty states, modals, cards, buttons, badges, alerts, data tables, or any component pattern. Covers decision framework for CSS-only vs partial vs helper vs ViewComponent, proper use of local_assigns, block yielding, capture for slots, variant patterns, and component galleries.
4active-model
Build plain Ruby objects that integrate with Rails forms, validations, serialization, and Action Pack — without a database. Use when building form objects, search forms, API wrappers, configuration objects, virtual models, or any PORO that needs model-like behavior. Triggers on "active model", "form object", "plain Ruby object", "non-database model", "ActiveModel", "PORO", "service object validation", "virtual model", "tableless model", "search form", "ActiveModel::API", "ActiveModel::Model".
4active-storage
Expert guidance for Rails Active Storage — file uploads, attachments, image variants, direct uploads, and cloud service configuration. Use when working with file uploads, has_one_attached, has_many_attached, image processing, variants, S3/GCS/Azure storage, direct uploads, or file attachment patterns in Rails applications.
4