When you build a web application, you might start with just one language in mind. I did. My first Rails app was in English, for an audience I assumed was mostly like me. Then, something happened. A user from Brazil asked if they could see it in Portuguese. Another from Japan wondered about Japanese support. Suddenly, my simple app needed to speak many languages. This is the world of internationalization, or i18n for short. It’s not just about swapping words; it’s about adapting your entire application to different cultures, formats, and expectations.
Rails comes with tools to help, but when you step into the real world, you often need more. Over the years, I’ve leaned on a set of Ruby gems that turn the complex job of going global into a manageable process. Let me walk you through them. I’ll show you what they do and how they work, with code you can try yourself.
The starting point for everything in Rails i18n is the i18n gem itself. It’s baked into Rails. Think of it as the engine. It provides a system to store your translations, usually in YAML files, and retrieve them based on the user’s preferred language. It handles the basic lookup.
But its real power comes from how you can bend it to your needs. For instance, you might store common translations in files, but more dynamic content in a database. You can chain these backends together. The gem will look in the first place, and if it doesn’t find the translation, it will check the next one. This is incredibly useful.
Here’s a setup I’ve used. It uses Redis for fast access to frequently changed translations, with simple YAML files as a fallback.
# In an initializer, like config/initializers/i18n.rb
I18n.backend = I18n::Backend::Chain.new(
I18n::Backend::KeyValue.new(Redis.current, namespace: 'i18n'),
I18n::Backend::Simple.new
)
You also need to think about fallback languages. If a user prefers Spanish from Mexico (es-MX), but you don’t have a specific translation, you might want to show general Spanish (es), and if that’s missing, show English (en). Setting this up is straightforward.
I18n.fallbacks = { 'es-MX' => ['es', 'en'], 'fr-CA' => ['fr', 'en'] }
Your translation files get organized. Instead of one huge file, you can structure them by part of the app. This keeps things clean.
# config/locales/es.yml
es:
users:
welcome: "Bienvenido, %{name}"
count:
one: "1 usuario"
other: "%{count} usuarios"
# config/locales/en.yml
en:
users:
welcome: "Welcome, %{name}"
count:
one: "1 user"
other: "%{count} users"
To use it in your app, you set the locale and ask for the translation. The t helper is everywhere in Rails views. In controllers or models, you use I18n.t.
I18n.locale = :es
puts I18n.t('users.welcome', name: 'Juan') # => "Bienvenido, Juan"
puts I18n.t('users.count', count: 5) # => "5 usuarios"
This foundation is crucial, but it’s just for static text. What about content that comes from your database, like product names or blog posts? That’s where the next gem comes in.
For a long time, when I needed database content in multiple languages, I reached for Globalize. It’s like giving your Active Record models the ability to speak many languages. You don’t create separate models for each language. Instead, you keep one Product model, and Globalize adds a product_translations table behind the scenes to hold the multilingual versions.
Setting it up involves telling your model which fields are translatable.
class Product < ApplicationRecord
translates :name, :description, fallbacks_for_empty_translations: true
end
You need to create the translation table via a migration. This is a one-time setup.
class CreateProductTranslations < ActiveRecord::Migration[7.0]
def change
create_table :product_translations do |t|
t.references :product, null: false, foreign_key: true
t.string :locale, null: false
t.string :name
t.text :description
t.timestamps
t.index [:product_id, :locale], unique: true # Important!
end
end
end
The magic is in how you use it. You work within a locale “bubble”. Inside that bubble, the translated fields just work.
product = Product.create(name: 'Coffee Mug') # Created with the default locale
I18n.with_locale(:es) do
product.update(name: 'Taza de Café')
end
# Now, depending on the locale, you get the right name.
I18n.locale = :en
product.name # => 'Coffee Mug'
I18n.locale = :es
product.name # => 'Taza de Café'
The fallbacks_for_empty_translations: true option is a lifesaver. If you have a Spanish name but no German one, and a German user visits, they’ll see the Spanish name (or whatever your fallback chain dictates) instead of a blank. It’s better than nothing.
Querying can be tricky. You can’t just do Product.where(name: 'Taza') because name isn’t a column on the products table. Globalize provides a way.
Product.joins(:translations).where(product_translations: { locale: 'es', name: 'Taza de Café' })
This approach is solid and well-understood. But I found myself in situations where I wanted more flexibility. Sometimes I wanted to store translations in a key-value store for some fields, and in a table for others. That’s when I discovered Mobility.
Mobility is a newer, very flexible gem. It doesn’t assume one storage method. You can choose: store translations in a table (like Globalize), in a JSON column right on your model’s table, or as key-value pairs in a separate table. You can even mix methods within the same model.
Here’s a model that uses different backends for different purposes.
class Article < ApplicationRecord
extend Mobility
# Big pieces of content go in a separate translation table
translates :title, :body, backend: :table
# Simple tags can go in a key-value store
translates :keywords, backend: :key_value
# Structured metadata might be perfect for JSONB
translates :meta, backend: :jsonb
end
The query interface is clean and reads well.
# Find articles with a specific title in the current locale
Article.i18n.where(title: 'Getting Started')
# More complex queries are possible too
Article.i18n { title.eq('Hello') & body.matches('%world%') }
You configure defaults and fallbacks globally for Mobility.
Mobility.configure do |config|
config.default_backend = :table
config.fallbacks = { 'de-AT' => [:de, :en] } # Austrian German falls back to German, then English
end
The flexibility is powerful, but it also means more decisions during setup. For most straightforward model translations, Globalize is simpler. For complex applications with varied translation storage needs, Mobility is a superb tool.
Now, managing all these translations in YAML files or a database is a technical task. What if your marketing team from Spain needs to correct some text? You don’t want to give them access to your codebase or database console. This is the problem Tolk solves.
Tolk provides a web interface for managing translations. It’s essentially a mini-app you mount inside your Rails application. Non-technical team members can log in, see all the translation keys, and edit the text for their language.
You mount it in your routes.
# config/routes.rb
Rails.application.routes.draw do
mount Tolk::Engine => '/translation-admin', as: 'tolk'
# ... your other routes
end
After visiting /translation-admin, you’ll see a list of languages. You can click on one and see every translation key from your master locale (like English), with a text box to fill in the equivalent. It shows you which translations are missing.
It can sync with your existing locale files, and you can download the updated YAML files when your editors are done. The configuration lets you hide keys you don’t want edited (like complex Active Record error messages).
# config/initializers/tolk.rb
Tolk.configure do |config|
config.ignore_keys = ['activerecord.attributes', 'number.*']
end
Crucially, you can control who accesses it by adding a simple authentication filter.
# In the same initializer
Tolk::ApplicationController.class_eval do
before_action :authenticate_translator!
def authenticate_translator!
redirect_to main_app.root_path unless current_user && current_user.translator?
end
end
I’ve used Tolk on projects with large, distributed teams. It puts the power of translation in the hands of the people who know the language best, without them needing to bother a developer.
As your application grows, your locale files can become messy. You might have keys you stopped using years ago, or you might be missing translations for new features. Finding these problems manually is a nightmare. This is where i18n-tasks shines.
i18n-tasks is a command-line tool that analyzes your code and your locale files. It tells you exactly what’s wrong. You run it in your terminal.
# Find all translation keys used in your code that are missing from your locale files
i18n-tasks missing
# Find all keys in your locale files that are never used in your code
i18n-tasks unused
# Check if your interpolated variables (like %{name}) are consistent across all languages
i18n-tasks check-consistent-interpolation
The output is clear and actionable. It can even fix some problems for you.
# Add missing keys to your locale files (as empty strings)
i18n-tasks add-missing
# Remove all unused keys from your locale files
i18n-tasks remove-unused
You configure it with a YAML file. You can tell it to ignore certain patterns, like keys from a third-party gem.
# config/i18n-tasks.yml
base_locale: en
locales: [en, es, fr, de]
ignore_unused:
- 'devise.*' # Ignore keys from the Devise authentication gem
- 'errors.messages.*' # Ignore standard Rails error messages
ignore_missing:
- 'simple_form.*' # We'll manage Simple Form translations separately
I run i18n-tasks missing as part of my test suite before any deployment. It’s a simple check that prevents showing ugly translation keys like en.users.show.title to your users.
So far, we’ve talked about your custom text and content. But a Rails application generates a lot of text itself: “Last updated at”, “1 error prohibited this from being saved”, month names, day names, and more. Translating all of this from scratch is a massive task. Fortunately, you don’t have to.
The rails-i18n gem is a community-driven collection of locale files for Rails. It provides translations for all the standard Rails components for dozens of languages. Need the German words for “January”, “February”, etc.? It’s in there. The standard Active Record error messages in French? It’s in there. Date and time formats appropriate for Japan? It’s in there.
You add the gem, and then you can load the locales you need.
# In config/application.rb or an initializer
# Load all available locales (be careful, this is many files)
I18n.load_path += Dir[Rails.root.join('config', 'locales', '*.{rb,yml}')]
I18n.load_path += Dir[Gem.loaded_specs['rails-i18n'].full_gem_path + '/rails/locale/*.yml']
# Or, selectively load only the locales you support
required_locales = [:en, :es, :fr, :ja]
required_locales.each do |locale|
I18n.load_path += Dir[Gem.loaded_specs['rails-i18n'].full_gem_path + "/rails/locale/#{locale}.yml"]
end
This gem gives you a huge head start. You can always override any of these standard translations in your own locale files if they don’t suit your application perfectly. It handles the tedious, repetitive work so you can focus on your application’s unique text.
Finally, let’s talk about URLs. If you have a blog post called “Getting Started,” you might want the URL to be /blog/getting-started. That’s a “slug.” But in Spanish, that post is called “Empezando.” You want the Spanish URL to be /es/blog/empezando. You need locale-aware, human-readable URLs. This is the job of Friendly_id.
Friendly_id is a gem that overrides Rails’ default numeric IDs (/posts/5) with slugs based on a model attribute (like the title). With a bit of extra configuration, it can manage these slugs per locale.
First, you set up your model.
class Article < ApplicationRecord
extend FriendlyId
friendly_id :title, use: [:slugged, :history, :scoped], scope: :locale
# Regenerate the slug only if the title for the current locale changes
def should_generate_new_friendly_id?
title_changed? || super
end
end
The key is scope: :locale. This tells Friendly_id to ensure slugs are unique within a locale. The same slug string can be used in English and Spanish without conflict.
Here’s how it works in practice.
I18n.locale = :en
english_article = Article.create(title: 'Winter Gardening Tips')
english_article.slug # => "winter-gardening-tips"
I18n.locale = :es
spanish_article = Article.create(title: 'Consejos de Jardinería de Invierno')
spanish_article.slug # => "consejos-de-jardineria-de-invierno"
# Now, finding the right article depends on the locale.
I18n.locale = :en
Article.friendly.find('winter-gardening-tips') # Finds the English article
I18n.locale = :es
Article.friendly.find('consejos-de-jardineria-de-invierno') # Finds the Spanish article
The :history option is great for SEO. If you change the title from “Winter Gardening” to “Cold Weather Gardening,” Friendly_id keeps the old slug in a history table. When someone visits the old URL, it can redirect them to the new one automatically.
Your routes need to support a locale prefix. A common pattern looks like this.
# config/routes.rb
scope "(:locale)", locale: /en|es|fr/ do
resources :articles
end
This creates routes like /articles/... and /es/articles/.... The friendly.find method, combined with the locale from the URL, will find the correct record with the correct localized slug.
These seven gems form a powerful toolkit. The core i18n gem is your foundation. Globalize or Mobility handle your database content. Tolk lets your team manage translations. i18n-tasks keeps your files clean. rails-i18n provides the standard Rails phrases. Friendly_id makes your URLs global-friendly.
You don’t need to use them all at once. Start with the basics. Add complexity as your application and your audience grow. I started with just the i18n gem and rails-i18n. Later, I added Globalize when I needed product descriptions in multiple languages. Later still, Tolk became essential when the translation workload grew.
Building for the world is a journey. It’s about respecting your users enough to meet them in their language, on their terms. These tools are your companions on that path. They handle the technical complexity so you can focus on creating an application that truly feels at home, anywhere.