Hook
Building a Rails application for a single market is straightforward, but scaling to multiple languages and regions introduces complexity that catches teams off guard. Internationalization in Rails: best practices explained will help you ship applications confidently across markets, handling not just translation strings but locale detection, date formatting, and user preferences as production concerns rather than afterthoughts.
Understanding Rails i18n Architecture
Rails ships with a mature internationalization framework that abstracts locale management, translation lookup, and formatting rules. The i18n gem (included by default) follows a simple but powerful pattern: store translations in organized files, reference them in code via helper methods, and switch locales dynamically based on request context or user preference.
The architecture separates concerns well. Translation files live in config/locales/, the I18n module handles lookup and fallback chains, and Rails controllers inherit built-in locale detection. This means you can add language support to an existing app without rewriting core logic—it's a straightforward integration point.
Organizing config/locales YAML Files
Translation files use YAML format and live in config/locales/. Each locale gets its own file or nested structure. A common pattern separates concerns into multiple files per language, making maintenance easier as your app grows.
# config/locales/en.yml
en:
activerecord:
models:
user: "User"
attributes:
user:
email: "Email Address"
password: "Password"
pages:
home:
title: "Welcome"
description: "Your trusted platform"
errors:
not_found: "The page you're looking for doesn't exist"
# config/locales/de.yml
de:
activerecord:
models:
user: "Benutzer"
attributes:
user:
email: "E-Mail-Adresse"
password: "Passwort"
pages:
home:
title: "Willkommen"
description: "Ihre vertrauenswürdige Plattform"
errors:
not_found: "Die gesuchte Seite existiert nicht"We organize translations into namespaces: activerecord for model validation messages (Rails auto-generates keys here), pages for view content, and errors for application-level messages. This structure scales well because it mirrors your app's mental model—you know exactly where to find a translation.
For larger apps, consider splitting into separate files per namespace:
# config/locales/en/activerecord.yml
en:
activerecord: { ... }
# config/locales/en/pages.yml
en:
pages: { ... }
# config/locales/en/errors.yml
en:
errors: { ... }This prevents any single locale file from becoming unwieldy. Rails loads all YAML files in config/locales/ automatically.
Lazy Lookups with t('.title')
Rails provides a shorthand syntax called lazy lookup that infers the translation key from your view's controller and action. Instead of writing t('pages.home.title'), you write t('.title') and Rails constructs the full key by prepending the controller and action name.
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
def home
# render views/pages/home.html.erb
end
end
# app/views/pages/home.html.erb
<h1><%= t('.title') %></h1>
<p><%= t('.description') %></p>
# Rails expands t('.title') to t('pages.home.title')
# Rails expands t('.description') to t('pages.home.description')Lazy lookups reduce boilerplate and keep translations organized by view. When you rename a view or move it to a different controller, the key path changes automatically—less manual refactoring.
In shared partials or mailers where the context isn't a controller action, use explicit keys:
# app/views/shared/_header.html.erb
<nav>
<%= link_to t('navigation.home'), root_path %>
<%= link_to t('navigation.about'), about_path %>
</nav>
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def welcome_email(user)
@greeting = t('emails.welcome.greeting')
mail(to: user.email, subject: t('emails.welcome.subject'))
end
endUse explicit keys for code that doesn't have a clear controller/action context. This keeps translations predictable across your codebase.
Detecting and Setting Locale from URL or User Preference
Rails defaults to the I18n.default_locale, but production apps need dynamic locale selection. The most common approaches are URL-based routing and database-stored user preferences.
URL-based locale works well for public-facing content. Add a locale segment to your routes:
# config/routes.rb
scope "/:locale", locale: /en|de|fr/ do
root "pages#home"
resources :users
end
# Matches /en/users, /de/users, /fr/users
# I18n.locale is set automatically by Rails before each requestWith this routing, URLs like /en/users and /de/users map to the same controller but with params[:locale] available. Rails automatically sets I18n.locale = params[:locale] if you add a before action:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_locale
private
def set_locale
I18n.locale = params[:locale] || I18n.default_locale
end
endDatabase-stored user locale works for authenticated users. Store the preference in your User model and set it in a before action:
# db/migrate/xxx_add_locale_to_users.rb
class AddLocaleToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :locale, :string, default: 'en'
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_locale
private
def set_locale
if current_user
I18n.locale = current_user.locale
else
I18n.locale = params[:locale] || request.env['HTTP_ACCEPT_LANGUAGE']&.scan(/^[a-z]{2}/)&.first || I18n.default_locale
end
end
endFor guest users, parse the Accept-Language header to detect browser language. Always fall back to your default locale.
Localizing Dates and Numbers
Translating strings is half the battle. Dates, times, and numbers need locale-aware formatting too. Rails uses CLDR data under the hood to format these according to regional conventions.
Store locale-specific date and number formats in your YAML files:
# config/locales/en.yml
en:
time:
formats:
default: "%B %d, %Y"
short: "%b %d"
number:
formats:
default:
precision: 2
separator: "."
delimiter: ","
# config/locales/de.yml
de:
time:
formats:
default: "%d. %B %Y"
short: "%d. %b"
number:
formats:
default:
precision: 2
separator: ","
delimiter: "."Use Rails helpers to format dates and numbers with these locale settings:
# In your view
<%= l(Date.today) %>
# With I18n.locale = :en => "January 15, 2024"
# With I18n.locale = :de => "15. Januar 2024"
<%= number_to_currency(99.99) %>
# With I18n.locale = :en => "$99.99"
# With I18n.locale = :de => "99,99 €"
<%= number_to_percentage(0.85) %>
# With I18n.locale = :en => "85.000%"
# With I18n.locale = :de => "85,000%"Rails provides several helpers: l() for dates/times, number_to_currency(), number_to_percentage(), and number_with_precision(). Each respects the current locale and applies formatting rules automatically.
For currency, configure which symbol to use per locale:
# config/locales/en.yml
en:
number:
currency:
format:
unit: "$"
precision: 2
separator: "."
delimiter: ","
# config/locales/de.yml
de:
number:
currency:
format:
unit: "€"
precision: 2
separator: ","
delimiter: "."Common Mistakes
Forgetting fallback chains: If a translation is missing in the current locale, Rails doesn't fail silently—it raises a MissingTranslationData error in production. Set a fallback chain so missing translations fall back to your default locale:
# config/initializers/i18n.rb
I18n.fallbacks = { de: :en, fr: :en }
# If a translation is missing in :de, Rails looks in :en nextHardcoding dates and numbers: Never format a date or number as a string in your view. Use Rails helpers (l(), number_to_currency()) so formatting respects the current locale. Hardcoded formatting breaks for non-English users.
Storing translated content in the database: For content that truly differs per locale (like blog post titles), store the locale as a separate column or use a translation gem like globalize3. Don't duplicate rows per language—this creates maintenance nightmares and breaks relationships.
Setting locale in the model: Locale is request context, not business logic. Store it in the controller or a session, not in the model. This keeps your domain clean and makes testing easier.
Ignoring plural rules: Many languages have complex plural rules. English has two forms (singular/plural), but Russian has four. Use Rails pluralization syntax:
# config/locales/en.yml
en:
items:
one: "You have %{count} item"
other: "You have %{count} items"
# In your view
<%= t('items', count: @items.size) %>Key Takeaways
- Organize translations in
config/locales/with a clear namespace structure (activerecord, pages, errors) so they scale with your app. - Use lazy lookups (
t('.key')) in views to reduce boilerplate and keep translations organized by view context. - Detect locale from the URL for public content or from user preferences in the database for authenticated users, with Accept-Language header as a fallback.
- Format dates, times, and numbers with Rails helpers (
l(),number_to_currency()) instead of hardcoding, so formatting respects the current locale. - Set up fallback chains to prevent MissingTranslationData errors in production when a translation is missing in the current locale.
- Handle plural rules explicitly in your YAML files—Rails supports complex pluralization for all languages automatically.






Comments
Loading comments…