i18n

Installation
SKILL.md

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:

  1. Every string in locale files — No hardcoded user-facing text in views, controllers, mailers, or models
  2. Lazy lookups everywhere — Use .title not books.index.title in views/controllers
  3. Organize by feature, not language — Split locale files by domain (models, views, defaults), not one giant file
  4. YAML is king — Use .yml files unless you need Ruby lambdas for date formats
  5. Fail loud in dev/test — Set raise_on_missing_translations = true so 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() and l() / 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: other only

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/timesstrftime 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

  1. Hardcoding strings"Record saved" instead of t(".success")
  2. Wrong YAML nesting — Forgetting locale key at top level, wrong indentation
  3. Not using lazy lookupst("users.show.title") instead of t(".title") in views/controllers
  4. Forgetting available_locales — Without it, arbitrary locale strings are accepted (security risk)
  5. Using I18n.locale = — Leaks across requests in threaded servers; use I18n.with_locale
  6. Pluralization without count — Returns raw hash instead of string
  7. Missing _html suffix — HTML in translations gets escaped without it
  8. Not quoting YAML booleanstrue, false, yes, no are parsed as booleans; quote them when used as keys
  9. 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-i18n gem for date/time/number defaults + pluralization rules
  • Test with raise_on_missing_translations = true
  • Add locale switcher UI + update default_url_options if URL-based

For detailed patterns, examples, and edge cases, see the references/ directory:

  • references/lookups.md — Translation lookup methods, interpolation, lazy lookups
  • references/locale-files.md — YAML patterns, file organization, date/time/number localization, custom backends
  • references/pluralization.md — Pluralization rules by language
  • references/model-translations.md — Active Record model/attribute/error translations
  • references/locale-switching.md — Locale switching strategies and fallback configuration
  • references/testing.md — Testing I18n, common gems (rails-i18n, i18n-tasks, mobility)
Related skills

More from thinkoodle/rails-skills

Installs
4
GitHub Stars
4
First Seen
7 days ago