Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 062 / 100

Internationalization in Rails: best practices explained

Ship in many languages.

Expona Admin7 min read

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.

ruby
# 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:

ruby
# 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.

ruby
# 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:

ruby
# 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
end

Use 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:

ruby
# 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 request

With 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:

ruby
# 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
end

Database-stored user locale works for authenticated users. Store the preference in your User model and set it in a before action:

ruby
# 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
end

For 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:

ruby
# 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:

ruby
# 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:

ruby
# 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:

ruby
# config/initializers/i18n.rb
I18n.fallbacks = { de: :en, fr: :en }
# If a translation is missing in :de, Rails looks in :en next

Hardcoding 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:

ruby
# 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles