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.
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.
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.
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.
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.
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.
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.
Thanks for reading. If you’ve got thoughts, send them my way.