Rails Internationalization (i18n): Complete Guide
Configure Rails I18n, organize config/locales/*.yml, automate YAML translations with AI, and let PTC (Private Translation Cloud) review your running Rails app on every release. By the end you will have a Rails app that responds to /es/... URLs, serves translated views across 40+ languages, and is verified by visual review.
This guide assumes you have an existing Rails 7+ app. The concepts apply to older Rails versions with minor API differences. The I18n gem itself has been stable for years. For the broader CI/CD localization story across stacks, see AI software localization built for CI/CD pipelines.
Configure Rails to load locales, recognize URLs, and switch per request
Rails internationalization requires three configuration steps. Set available locales, add locale to your URLs, and make Rails load the correct locale per request. You also install the rails-i18n gem for locale data.
Declare available locales in config/application.rb
In config/application.rb, tell Rails which languages the app supports and set a default:
module MyApp
class Application < Rails::Application
config.i18n.available_locales = [:en, :es, :fr, :de, :ja]
config.i18n.default_locale = :en
config.i18n.fallbacks = true
config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}')]
end
end
The load_path += Dir[...] line is the key one. By default Rails loads only config/locales/*.yml (one level deep). Adding the recursive glob lets you organize translations by namespace, model, or feature in subdirectories.
Add :locale to your URL scope
Add a :locale scope so each language has its own path, like /en/time or /es/time:
# config/routes.rb
scope "/:locale" do
get '/time', to: 'home#index', as: :time_display
end
Switch locale per request with I18n.with_locale
In ApplicationController, load the right locale from the URL and include it in all generated links:
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
I18n.with_locale is the idiomatic way to scope a locale to a request. It sets the locale, runs the block, restores the previous locale, and is thread-safe. default_url_options ensures every URL Rails generates carries the current locale, so users stay in their selected language as they navigate.
Install rails-i18n for translated month names and pluralization rules
The rails-i18n gem provides locale data for dozens of languages. That gem ships translated month names, pluralization rules, and default Rails error messages. So you do not translate that boilerplate yourself.
# Gemfile
gem 'rails-i18n'
bundle install
Your Rails app is now fully configured for internationalization.
Add a language switcher with url_for(locale: :code)
Because default_url_options automatically includes the locale in every generated URL, your switcher only needs to update the :locale parameter while keeping the user on the same page.
<%# app/views/layouts/application.html.erb %>
<nav class="language-switcher">
<%= link_to "English", url_for(locale: :en) %> |
<%= link_to "Español", url_for(locale: :es) %> |
<%= link_to "Deutsch", url_for(locale: :de) %>
</nav>
Each link uses url_for(locale: :code) to generate a URL with the specified locale. When users click, switch_locale in ApplicationController detects the change and Rails renders the page in the new language.
Replace hardcoded text in views with t(:key)
Hardcoded text will not appear in your YAML files, which means it cannot be translated. Always use translation keys for any user-facing text.
<%# Correct - uses translation keys %>
<h1><%= t(:hello) %></h1>
<p><%= t(:current_time, time: @time) %></p>
<button id="click-me"><%= t(:refresh) %></button>
<%# Incorrect - hardcoded text %>
<h1>Hello</h1>
<p>Current time: <%= @time %></p>
<button>Refresh</button>
In controllers (for flash messages):
def create
if @post.save
redirect_to @post, notice: t('flash.post.created')
else
flash.now[:alert] = t('flash.post.error')
render :new, status: :unprocessable_entity
end
end
In models (for validation messages, use the Active Model conventions):
# config/locales/en.yml
en:
activerecord:
attributes:
post:
title: "Title"
errors:
models:
post:
attributes:
title:
blank: "is required"
Interpolate variables with %{name}
%{name} in the YAML is replaced by the value you pass to t():
current_time: "Current time: %{time}"
<%= t(:current_time, time: @time) %>
Use lazy lookup (.key) for view-scoped translations
When your translation keys are organized to match your view folder structure, use a leading dot. Rails fills in the prefix from the current view:
<%# Instead of this: %>
<%= t('home.index.hello') %>
<%# Use this: %>
<%= t('.hello') %>
Rails sees you are in home/index.html.erb and prepends home.index.. If you rename or relocate a view, lazy lookup paths update automatically.
Organize config/locales/ by feature instead of one giant en.yml
For a real-world app, one giant en.yml becomes unmaintainable. Organize per feature:
config/locales/
en.yml # global / shared keys
models/
post.en.yml
views/
posts.en.yml
home.en.yml
flash.en.yml
A sample config/locales/views/posts.en.yml:
en:
posts:
index:
title: "All Posts"
empty: "No posts yet"
new:
title: "Create a New Post"
show:
published_at: "Published %{date}"
comments_count:
zero: "No comments yet"
one: "1 comment"
other: "%{count} comments"
Conventions:
- Nested keys group related strings and enable lazy lookup.
%{name}interpolation for inline variables.zero/one/otherpluralization keys for countable nouns. Rails routes to the right key based oncount::t('posts.show.comments_count', count: 5).
Add JavaScript strings to YAML too
Rails does not automatically extract text from JavaScript files. Any client-side text (alerts, tooltips, confirmation messages) needs to live in your YAML so it gets translated alongside everything else:
en:
confirm: "Are you sure?"
When you translate with PTC in the next section, these strings travel with the rest.
Translate config/locales/*.en.yml with PTC in 4 steps
Once your translation files are in place, you need versions for every target language. PTC handles this:
- Start a PTC project and pick English as the source. The free trial covers 20,000 words into 2 languages, no credit card.
- Upload your
config/locales/*.en.ymlfiles. PTC parses the YAML, recognizes Rails pluralization keys (zero,one,other,few,many), and preserves%{name}interpolations. - Add a short description of your Rails app and audience. PTC uses this context to translate with the right tone and terminology.
- Pick target languages and confirm. PTC produces
posts.es.yml,posts.fr.yml,posts.de.yml, structurally identical to the source with translated values. Plural forms are generated per language. Polish getsone/few/many/other. Japanese gets onlyother.
Drop the files back into config/locales/views/, restart your Rails server, and the app serves the new languages.
For Git-driven projects, point PTC at your repo. New strings in any *.en.yml trigger automatic translation. PTC opens a PR with the updated target-language files. See the PTC API reference for the sync flow.
Use I18n.with_locale in background jobs, mailers, and cron tasks
The I18n.with_locale(locale, &block) pattern is critical for any code that runs outside a normal request. That includes background jobs, cron tasks, and mailers.
# Background job: send an email in the user's preferred locale
class WelcomeEmailJob < ApplicationJob
def perform(user_id)
user = User.find(user_id)
I18n.with_locale(user.preferred_locale) do
UserMailer.welcome(user).deliver_now
end
end
end
Without with_locale, the job runs in whatever locale was active when the worker started. Usually :en, regardless of the user's preference. With it, the email renders in the user's language and the locale is reset when the block finishes.
Export Rails translations to JSON with i18n-js for client-side use
Rails-rendered pages get translations via t() in ERB. JavaScript that runs in the browser does not see Rails' I18n directly. The cleanest pattern uses the i18n-js gem to export translations as JSON and load them client-side.
# Gemfile
gem 'i18n-js'
bundle install
i18n init
Update the generated config to export translations to public/locales.json:
# config/i18n.rb
require "i18n-js"
I18n::JS.config do |config|
config.export_i18n_js = false
config.translations_path = "public/locales.json"
end
Generate the JSON file:
i18n export
This reads all your YAML files (en.yml, es.yml, de.yml) and writes public/locales.json with every translation in a JS-readable format.
Pin i18n-js with Rails 7+ Importmap:
# config/importmap.rb
pin "i18n-js", to: "https://esm.sh/i18n-js@latest/dist/import/index.js"
pin "load_locale", to: "load_locale.js"
Create a loader:
// app/javascript/load_locale.js
export async function loadLocale() {
const response = await fetch('/locales.json');
return await response.json();
}
Pass the current locale to JavaScript via the <body> tag:
<%# app/views/layouts/application.html.erb %>
<body data-locale="<%= I18n.locale %>">
Then use translations in your JavaScript:
// app/javascript/application.js
import { I18n } from "i18n-js";
import { loadLocale } from "./load_locale";
document.addEventListener('turbo:load', async () => {
const translations = await loadLocale();
const i18n = new I18n(translations);
i18n.locale = document.body.dataset['locale'];
if (confirm(i18n.t('confirm'))) {
// User clicked OK
}
});
i18n.t() works like Rails' t helper. When users switch languages, JavaScript uses the correct translations from the data-locale attribute.
Translate ActiveRecord content with the PTC API
The pattern above translates static strings in your YAML files. ActiveRecord content (user posts, comments, product descriptions stored as user input) does not appear in YAML and needs a different approach. The PTC REST API translates this content on demand with Bearer-token authentication, using the same glossary and brand voice as your config/locales/*.yml files. POST your content to PTC, then either receive a callback when translations are ready or poll status from a background job.
Localize dates, numbers, and currency with Rails helpers
Dates and times. Use the l helper (short for localize):
<%= l Time.now, format: :long %>
The rails-i18n gem provides default date/time formats for many languages, including translated month names and locale-specific formatting. You can define custom formats in your YAML files.
Numbers and currency. Rails includes locale-aware helpers:
<%= number_to_currency(100, locale: :es) %> <!-- 100,00 € -->
<%= number_with_delimiter(1000000) %> <!-- 1,000,000 -->
These respect locale conventions for decimal separators, thousand delimiters, and currency symbols.
Localized views per language. For pages with significantly different content per locale, create separate view files. Rails renders the appropriate view based on the current locale:
app/views/pages/
about.html.erb <!-- Default -->
about.es.html.erb <!-- Spanish version -->
about.de.html.erb <!-- German version -->
Visual translation review of your running Rails app - ship without manual QA per release
After PTC translates your config/locales/*.yml, the rendered Rails app still needs verification. A translated label may overflow a button in German. A French validation message may use the wrong grammatical form. A hardcoded English string in an ERB template (no t() call) will render untranslated regardless of how many languages you ship.
PTC's AI Visual QA replaces the manual QA pass. For Rails apps (browser-based), install the PTC browser extension and record a walkthrough of your app's critical screens (sign-in, main flows, admin pages). From then on, PTC replays the recording in every target language after every translation update, captures each screen, and reports back:
- Fixes in the YAML files when PTC controls them. PTC re-translates a wrong sense, picks a shorter synonym, regenerates a plural form.
- Cursor / Claude Code prompts when the issue lives in your Ruby or ERB code. A hardcoded English string outside
t(), a flash message built by concatenation instead of interpolation, a helper that should useI18n.lfor date formatting.
The deliverable: a verified multilingual Rails app per release. Not just translated YAML.
Translate release notes, Devise mailers, and marketing pages
Your release notes, customer-facing emails sent through Devise or Action Mailer, and marketing pages live outside config/locales. PTC's Paste to Translate handles that copy in the same project. Paste the source text in the PTC dashboard, choose target languages, get back translations that use the same glossary and brand voice as your YAML translations.
Translate your config/locales/*.yml files with PTC
Start your free 30-day trial - 20,000 words into 2 languages, no credit card. Upload your YAML files, get translated versions in minutes, then install the browser extension to verify your running Rails app.
Related:
- PTC API reference - REST endpoints for CI integration.
- AI software localization built for CI/CD pipelines - service overview for engineering teams.
- Official Rails i18n guide - the canonical reference.