How to Localize a Ruby on Rails App: A Practical Guide

12 min read
by Arvid Andersson
Ruby on Rails i18n YAML Localization
How to Localize a Ruby on Rails App: A Practical Guide

Introduction

Rails ships with a powerful I18n framework out of the box. You define translations in YAML files, reference them with t() in your views, and Rails resolves the right string based on the current locale.

If you're building a Rails app that needs to support more than one language, you're in luck. Rails has had first-class internationalization support since version 2.2, and it's one of the more thoughtful i18n implementations in any web framework.

The I18n gem ships with every Rails app. You don't need to install anything extra to get started. But there's a difference between getting started and getting it right, and that gap is where most teams lose time.

This guide covers the practical side: how to set things up, how the system actually works under the hood, and where the sharp edges are. I've localized several Rails apps over the years, and the fundamentals are the same whether you're adding a second language to an existing app or starting fresh with Rails 8. We'll go from initial setup to pluralization, date formatting, and the gotchas that trip teams up.

How Rails i18n Works

Rails i18n follows a simple pattern: you store translations in YAML files, reference them with keys in your code, and the framework resolves the correct string based on the current locale.

The core components are:

  • Locale files – YAML files in config/locales/ that contain your translations
  • t() helper – short for I18n.translate(), the main way you access translations
  • l() helper – short for I18n.localize(), used for formatting dates and times
  • I18n.locale – the current locale, typically set per-request

When you call t('hello'), Rails looks up the key hello in the YAML file for the current locale. If the locale is :sv, it checks config/locales/sv.yml. If the key doesn't exist, you'll get a translation missing indicator, unless you've enabled fallbacks (covered in the gotchas section), which lets Rails check the default locale instead.

Here's what a minimal locale file looks like:

# config/locales/en.yml
en:
  hello: "Hello"
  goodbye: "Goodbye"
# config/locales/sv.yml
sv:
  hello: "Hej"
  goodbye: "Hejdå"

That's the basic contract. Everything else builds on top of this.

Setting It Up From Scratch

Configure Your Application

Open config/application.rb and set your available locales and default:

# config/application.rb
module MyApp
  class Application < Rails::Application
    # Set the default locale
    config.i18n.default_locale = :en

    # Whitelist available locales
    config.i18n.available_locales = [:en, :sv, :de, :fr]

    # Load locale files from nested directories
    config.i18n.load_path += Dir[
      Rails.root.join("config", "locales", "**", "*.yml")
    ]
  end
end

The load_path line is important if you want to organize locale files into subdirectories, which you will once you have more than a handful of translations.

Set the Locale Per Request

The most common approach is to detect the locale from the URL, a subdomain, or the Accept-Language header. Here's a typical ApplicationController setup:

class ApplicationController < ActionController::Base
  around_action :switch_locale

  private

  def switch_locale(&action)
    locale = extract_locale || I18n.default_locale
    I18n.with_locale(locale, &action)
  end

  def extract_locale
    locale = params[:locale]
    return unless locale.present?

    locale if I18n.available_locales.map(&:to_s).include?(locale)
  end
end

Important: Use I18n.with_locale (not I18n.locale =). The block form is thread-safe and resets the locale after the request completes. Setting I18n.locale directly can leak between requests in threaded servers like Puma, which is the default in Rails 8.

Add Locale to Your Routes

To include the locale in the URL (/sv/articles, /en/articles), scope your routes:

# config/routes.rb
Rails.application.routes.draw do
  scope "(:locale)", locale: /#{I18n.available_locales.join("|")}/ do
    resources :articles
    root "home#index"
  end
end

The parentheses make the locale optional, so /articles still works and falls back to the default locale. You'll also want a default_url_options method in your controller:

def default_url_options
  { locale: I18n.locale }
end

This ensures all generated URLs include the current locale automatically.

Using Translations in Practice

Basic Lookups

The t() helper works in views, controllers, mailers, and helpers:

<%# In a view %>
<h1><%= t("articles.index.title") %></h1>
<p><%= t("articles.index.subtitle") %></p>
# config/locales/en.yml
en:
  articles:
    index:
      title: "All Articles"
      subtitle: "Browse our latest posts"

Lazy Lookups

Rails has a convenient shorthand called lazy lookups. Inside a view, t('.title') is automatically scoped to the current controller and action:

<%# app/views/articles/index.html.erb %>
<h1><%= t(".title") %></h1>
<%# This looks up: articles.index.title %>

This is one of those Rails conventions that feels magical until you understand the pattern. The key is derived from the view path: app/views/articles/index.html.erb becomes articles.index. Lazy lookups keep your view code clean and make it obvious which translations belong where.

Variable Interpolation

You can pass variables into translations using the %{variable} syntax:

en:
  welcome: "Welcome back, %{name}!"
  items_count: "You have %{count} items in your cart"
<%= t("welcome", name: @user.name) %>
<%# => "Welcome back, Alice!" %>

HTML-Safe Translations

If a translation contains HTML, append _html to the key name:

en:
  terms_html: "By signing up you agree to our <a href='/terms'>Terms of Service</a>"
<%= t("terms_html") %>
<%# Marked as html_safe automatically, no need for raw() %>

Rails automatically marks _html keys as HTML-safe. Without the suffix, HTML tags would be escaped and rendered as literal text.

Pluralization

Pluralization in Rails uses a :count parameter to select the right form. For English, you need two forms:

en:
  notifications:
    one: "You have %{count} notification"
    other: "You have %{count} notifications"
<%= t("notifications", count: @count) %>
<%# count=1 => "You have 1 notification" %>
<%# count=5 => "You have 5 notifications" %>

Where It Gets Interesting: Other Languages

English only has two plural categories: one and other. But languages vary dramatically in their plural rules. This is defined by the Unicode CLDR (Common Locale Data Repository), and the differences are significant:

Language Plural categories
English one, other
Czech one, few, other
Polish one, few, many, other
Arabic zero, one, two, few, many, other

Rails' built-in pluralization only handles the English one/other pattern. For proper pluralization in other languages, you need the rails-i18n gem:

# Gemfile
gem "rails-i18n", "~> 8.0"

This gem provides locale-specific plural rules for 100+ languages, plus default translations for common Rails strings (validation messages, date names, etc.). Without it, you'll get incorrect pluralization in most non-English languages.

Here's what Polish looks like with proper plural rules:

pl:
  notifications:
    one: "Masz %{count} powiadomienie"
    few: "Masz %{count} powiadomienia"
    many: "Masz %{count} powiadomień"
    other: "Masz %{count} powiadomień"

If you skip the rails-i18n gem and only provide one and other, Polish users will see grammatically wrong text for counts like 2, 3, 4, 22, 23, etc. It's one of the most common localization bugs.

Date, Time, and Number Formatting

The l() helper localizes dates and times according to the current locale's format rules:

<%= l(Date.today, format: :long) %>
<%# English: "March 5, 2026" %>
<%# Swedish: "5 mars 2026" %>
<%# German: "5. März 2026" %>

Date and time formats are defined in your locale files:

sv:
  date:
    formats:
      default: "%Y-%m-%d"
      long: "%-d %B %Y"
      short: "%-d %b"
    month_names:
      - ~
      - januari
      - februari
      - mars
      # ...

For numbers and currencies, Rails provides built-in helpers:

<%= number_to_currency(1499.90, locale: :sv) %>
<%# => "1 499,90 kr" %>

<%= number_to_currency(1499.90, locale: :en) %>
<%# => "$1,499.90" %>

The rails-i18n gem includes these format definitions for all supported locales, so you don't have to write them yourself.

Organizing Locale Files at Scale

A fresh Rails app has a single config/locales/en.yml. This works fine for 20 keys. It does not work fine for 2,000.

Split by Feature

The most maintainable approach is to mirror your app's structure:

config/locales/
├── en/
│   ├── articles.yml
│   ├── users.yml
│   ├── notifications.yml
│   ├── mailers.yml
│   └── activerecord.yml
├── sv/
│   ├── articles.yml
│   ├── users.yml
│   ├── notifications.yml
│   ├── mailers.yml
│   └── activerecord.yml
└── de/
    └── ...

Each file contains only the translations for that feature, nested under the locale key:

# config/locales/en/articles.yml
en:
  articles:
    index:
      title: "All Articles"
    show:
      published_at: "Published %{date}"

This structure makes it easy to find translations and lets teams work on different features without merge conflicts.

Find Missing and Unused Keys

The i18n-tasks gem is invaluable for keeping locale files in sync:

# Gemfile
gem "i18n-tasks", group: :development
# Find keys that exist in English but are missing in other locales
i18n-tasks missing

# Find keys defined in locale files but never used in code
i18n-tasks unused

# Add missing keys to all locales (with placeholders)
i18n-tasks add-missing

On larger projects, running i18n-tasks missing in CI prevents shipping with missing translations. It gives you a clear check for keys that still need to be sent for translation.

If you use a tool like Localhero.ai, you can keep your workflow source-language-first: developers add new keys only in the source locale, and Localhero handles translating and adding those keys to the target locale files automatically.

Translating ActiveRecord Models

Rails can translate model names and attribute names, which affects form labels, error messages, and other auto-generated text:

en:
  activerecord:
    models:
      article:
        one: "Article"
        other: "Articles"
    attributes:
      article:
        title: "Title"
        body: "Content"
        published_at: "Publication date"
    errors:
      models:
        article:
          attributes:
            title:
              blank: "can't be empty, every article needs a title"

This means Article.model_name.human returns the translated model name, and form helpers like form.label :title automatically use the translated attribute name.

Common Gotchas

1. Thread Safety with I18n.locale

A common bug I've seen with Rails i18n:

# BAD - leaks locale between requests in Puma
def set_locale
  I18n.locale = params[:locale]
end

# GOOD - resets locale after the block
def switch_locale(&action)
  I18n.with_locale(params[:locale], &action)
end

With Puma's default thread pool (Rails 8 default), setting I18n.locale directly means the locale persists on that thread. The next request handled by the same thread inherits whatever locale the previous request set, so one user can end up seeing another user's language.

2. Missing Translation Handling

By default, Rails wraps missing translations in a <span class="translation_missing"> tag with the humanized key name as text. This is easy to miss visually, especially in production.

# config/environments/development.rb
config.i18n.raise_on_missing_translations = true

This raises an exception when a translation is missing, which makes it impossible to ship with forgotten keys. In production, consider logging missing translations instead of raising.

3. Fallback Chains

When a translation is missing in the current locale, Rails can fall back to other locales:

# config/application.rb
config.i18n.fallbacks = true
# Or explicitly: config.i18n.fallbacks = { sv: :en, de: :en }

With fallbacks = true, Rails falls back to the default locale. You can also define explicit chains. For example, Austrian German falling back to German before English:

config.i18n.fallbacks = { "de-AT": [:de, :en] }

4. YAML Gotchas

YAML has some surprising parsing behaviors that bite i18n developers:

# These are parsed as booleans, not strings!
en:
  yes_label: yes    # => true (boolean)
  no_label: no      # => false (boolean)
  on_label: on      # => true (boolean)

# Fix: quote them
en:
  yes_label: "yes"
  no_label: "no"
  on_label: "on"

Also watch out for keys that look like numbers (404: "Not found" should be "404": "Not found") and colons in values (always quote strings containing :).

For more interesting details on YAML, check out noyaml.com.

Automating Your Translation Workflow

Setting up the i18n framework is one thing. Keeping translations in sync across 5, 10, or 30 languages as your app evolves is another challenge entirely.

The typical pain point: a developer adds new keys in English, but the Swedish, German, and French locale files don't get updated until someone manually notices. By then, you've shipped with missing translations. This is the exact problem I'm building Localhero.ai to solve.

The second pain point is consistency. Even when translations exist, they can drift from your product voice, naming conventions, and tone across features. For product teams, that matters as much as coverage.

A few ways to handle this:

  • i18n-tasks catches missing keys in CI, but doesn't generate the translations
  • Human translators deliver quality but introduce delays, and new features can wait days for translations
  • Build your own LLM pipeline gives you full control, but you have to maintain prompts, quality checks, and infrastructure, and still provide a workflow for non-technical teammates to review and adjust translations
  • AI-powered tools like Localhero.ai integrate directly into your GitHub workflow, translating new keys automatically on every pull request while following your glossary, brand voice, and style-guide rules

The goal is to make translations part of the development flow, not a separate step. If you're interested in CI/CD integration, I wrote a detailed guide on automating i18n with GitHub Actions.

FAQ

Do I need the rails-i18n gem? Yes, for any app supporting non-English languages. It provides plural rules, date/time formats, and default Rails translations for 100+ locales. Without it, pluralization breaks in most languages.

What's the best way to detect the user's locale? URL-based detection (like /sv/articles) is the most SEO-friendly and cacheable approach. You can combine it with the Accept-Language header as a fallback for the initial redirect.

How do I handle translations for database content (like blog posts)? The I18n framework handles static UI strings. For translating database content, look at the Mobility gem, which adds translated attributes to ActiveRecord models with multiple storage strategies.

Can I use JSON instead of YAML for locale files? Not by default. In mainstream Rails setups, locale files are YAML (.yml) or Ruby (.rb). If your tooling outputs JSON, convert it to YAML as part of your pipeline or use a custom I18n backend/parser.

How many translation keys is typical for a production app? A medium SaaS app typically has 500-2,000 keys. Large apps can exceed 10,000. File organization and tooling become critical above ~500 keys.

Cover photo by Daniel Miksha on Unsplash.

Ready to Ship Global?

Try Localhero.ai's AI-powered translation management for your project. Start shipping features without translation delays.

No credit card required • Setup in a few minutes • Cancel anytime