Internationalization in Rails

How I structured i18n for a Rails app that shipped into a non-English market. Locale resolution, file layout, DB-backed translations with Mobility, and a CI check that catches missing keys before launch.

The week we flipped the German locale on for the flagship SaaS I’d built end to end at a London agency, the signup page broke. Not visually broken. Logically broken. The free plan card said “0 Produkte” when the user had zero items in their cart, then “1 Produkten” for one item, then “2 Produkten” for two. The middle one is wrong. German has two plural forms for cardinal numbers and we’d shipped one. Someone on the launch Slack channel posted a screenshot at 8:54 in the morning local. By 9:02 the marketing lead asked if we needed to roll back the campaign.

We didn’t roll back. We patched the pluralization rules and shipped a fix in about forty minutes. But that morning is the reason I have opinions about how to structure i18n in a Rails app before the marketing team books a launch date.

This is the version I wish we’d had.

What Rails gives you out of the box

Rails ships with I18n and it’s good. It does locale resolution, fallback chains, pluralization, interpolation, date and number formats. You point it at YAML files and call t("key") and most of the time it does what you expect.

The thing it doesn’t do is tell you when a key is missing in production. It returns a “translation missing” string instead of raising. Which means a half-translated French page will quietly ship to French users and you’ll learn about it from a support ticket.

So step one is making missing keys loud.

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

# config/initializers/i18n.rb
if Rails.env.production?
  I18n.exception_handler = lambda do |exception, locale, key, options|
    Rails.logger.warn("i18n_missing: #{locale} #{key}")
    Sentry.capture_message("i18n_missing", extra: { locale: locale, key: key })
    # Fall back to English so users see something sensible.
    I18n.translate(key, **options.merge(locale: I18n.default_locale, raise: false))
  end
end

In tests, raise. In production, fall back to the default locale and log loudly. Sentry will tell you within a day which keys are actually missing in real traffic. We caught two missing German keys in the first hour after launch this way, neither of which had shown up in QA.

Locale resolution

Pick one strategy and own it. Don’t accept five.

For that app we used path-prefix locales for SEO surfaces (/de/pricing, /fr/pricing) and a per-user preferred_locale column for the authenticated app. The order I resolve in is:

class ApplicationController < ActionController::Base
  around_action :switch_locale

  AVAILABLE_LOCALES = %i[en de fr tr].freeze

  private

  def switch_locale(&action)
    locale = resolved_locale
    I18n.with_locale(locale, &action)
  end

  def resolved_locale
    [
      params[:locale]&.to_sym,
      current_user&.preferred_locale&.to_sym,
      locale_from_accept_language,
      I18n.default_locale,
    ].compact.find { |l| AVAILABLE_LOCALES.include?(l) }
  end

  def locale_from_accept_language
    return nil if request.env["HTTP_ACCEPT_LANGUAGE"].blank?

    request.env["HTTP_ACCEPT_LANGUAGE"]
      .scan(/[a-z]{2}/)
      .map(&:to_sym)
      .find { |l| AVAILABLE_LOCALES.include?(l) }
  end
end

The explicit URL param wins. Then the user’s saved preference. Then Accept-Language. Then the default. The AVAILABLE_LOCALES whitelist is the part that actually matters. If you don’t filter, a bot will ask for zh-Hans-CN and your I18n.locale setter will happily set it and then your YAML lookup will fall back to English on every call, and your p95 will mysteriously climb. We saw that one too.

How to organize the YAML files

The convention config/locales/en.yml works fine until you have three locales and forty features. Then it stops working.

What I do now is one folder per locale and one file per feature.

config/locales/
  en/
    common.yml
    pricing.yml
    checkout.yml
    emails.yml
  de/
    common.yml
    pricing.yml
    checkout.yml
    emails.yml

Then in config/application.rb:

config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.yml")]
config.i18n.available_locales = %i[en de fr tr]
config.i18n.default_locale = :en
config.i18n.fallbacks = { de: :en, fr: :en, tr: :en }

Fallbacks are non-negotiable. If de.pricing.cta_primary is missing, fall back to English and log. Better English copy than a “translation missing” string in front of a paying customer.

DB-backed translations with Mobility

YAML is right for static product copy. It’s wrong for user-generated content. Course titles, lesson descriptions, blog posts authored by creators. Those need to be translated per record, in the database, and editable by humans who aren’t engineers.

I’ve used mobility for this on a few products. It stores translations in side tables and exposes them as ordinary attributes.

# Gemfile
gem "mobility", "~> 1.3"
class Course < ApplicationRecord
  extend Mobility

  translates :title, type: :string, backend: :table
  translates :description, type: :text, backend: :table

  validates :title, presence: true
end

A migration that adds the translation table:

class CreateCourseTranslations < ActiveRecord::Migration[7.1]
  def change
    create_table :course_translations do |t|
      t.references :course, null: false, foreign_key: { on_delete: :cascade }
      t.string :locale, null: false, limit: 8
      t.string :title
      t.text :description
      t.timestamps
    end

    add_index :course_translations, %i[course_id locale], unique: true
    add_index :course_translations, :locale
  end
end

In the app you write course.title and Mobility resolves it against the current I18n.locale. To write a German title:

Mobility.with_locale(:de) { course.update!(title: "Anfaengerkurs") }

Two things I’d flag from running this in production.

One, eager-load the translation table when you list records. Course.i18n.includes(:translations).all is what you want. Without it you’ll N+1 across hundreds of locale joins per page and your index action will die.

Two, the column_fallback setting matters. Set it so that if a German translation doesn’t exist for a record, Mobility falls back to the English row instead of returning nil. Otherwise your view layer is full of course.title.presence || course.title(locale: :en) and that gets old fast.

Cache keys and locales

Locales touch caches in ways people forget about. When you cache anything translated, locale is part of the cache key. Always. cache [I18n.locale, course] not cache course. Russian doll caching with a locale at the top of the key. Don’t be clever.

A CI check for missing keys

The single most useful thing I add to any multi-locale Rails app is a script that fails the build when a locale is missing a key the default locale has.

# scripts/i18n_audit.rb
require "yaml"

REQUIRED = :en
LOCALES = %i[de fr tr]

def flatten_keys(hash, prefix = nil)
  hash.flat_map do |key, value|
    path = [prefix, key].compact.join(".")
    value.is_a?(Hash) ? flatten_keys(value, path) : [path]
  end
end

required_keys = Dir["config/locales/#{REQUIRED}/*.yml"].flat_map do |file|
  flatten_keys(YAML.load_file(file)[REQUIRED.to_s])
end.uniq

missing = {}
LOCALES.each do |locale|
  locale_keys = Dir["config/locales/#{locale}/*.yml"].flat_map do |file|
    data = YAML.load_file(file)[locale.to_s] || {}
    flatten_keys(data)
  end.uniq

  diff = required_keys - locale_keys
  missing[locale] = diff if diff.any?
end

if missing.any?
  missing.each { |loc, keys| puts "#{loc} missing: #{keys.join(', ')}" }
  exit 1
end

Wire it into the test step in CI:

# .github/workflows/ci.yml (excerpt)
- name: i18n audit
  run: bundle exec ruby scripts/i18n_audit.rb

You’ll catch the “0 Produkte / 1 Produkten / 2 Produkten” class of bug at PR time, not at launch time.

Takeaways

  • Make missing keys loud. Raise in test, fall back and log in production.
  • One locale, one folder, one file per feature. Stop fighting one giant YAML.
  • Whitelist locales before you set them. Bots will ask for everything.
  • Mobility is right for user-generated content. Eager-load the translation table.
  • Locale is part of every cache key. Always.
  • Audit missing keys in CI. The bug you want to ship is the one you find in PR.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.