In this tutorial, you’ll learn how to get started with Rails i18n (internationalization) to translate your application into multiple languages. We’ll cover everything you need to know: working with translations, localizing dates and times, and switching locales. Along the way, we’ll also touch on the importance of software localization for ensuring your app delivers a smooth experience for users across different regions.
To make things practical, we’ll build a sample application and enhance it step by step. By the end, you’ll have a solid understanding of Rails I18n and the confidence to implement it in real-world projects.
The source code for this tutorial can be found on GitHub.
Preparing for Rails i18n
As mentioned earlier, we’ll see all the relevant concepts in action. Let’s start by creating a new Rails application:
rails new LokaliseI18n
This tutorial uses Rails 8, but most of the concepts apply to earlier versions as well.
Generating static pages
Create a StaticPagesController
with an index
action for the main page::
rails g controller StaticPages index
Update the views/static_pages/index.html.erb
file to include some sample content:
<h1>Welcome!</h1> <p>We provide some fancy services to <em>good people</em>.</p>
Adding a feedback page
Let’s add a feedback page where users can share their (hopefully positive) opinions. Each piece of feedback will include the author’s name and the message. To generate the necessary files, run:
rails g scaffold Feedback author message
We’re only interested in two actions:
- new: Displays a form for posting feedback and lists all existing reviews.
- create: Validates and saves the new feedback.
Modify the new
action to fetch all feedback from the database, ordered by creation date:
# feedbacks_controller.rb # ... def new @feedback = Feedback.new @feedbacks = Feedback.order(created_at: :desc) end
In the create
action, redirect users back to the feedback page after a successful form submission:
# feedbacks_controller.rb # ... def create @feedback = Feedback.new(feedback_params) if @feedback.save redirect_to new_feedback_path else @feedbacks = Feedback.order(created_at: :desc) render :new, status: :unprocessable_entity end end
Views and routes
Update the new
view to display all feedback:
<!-- views/feedbacks/new.html.erb --> <!-- other code goes here... --> <%= render @feedbacks %>
Next, tweak the partial for individual feedback entries:
<!-- views/feedbacks/_feedback.html.erb --> <article id="<%= dom_id(feedback) %>"> <em> <%= tag.time feedback.created_at, datetime: feedback.created_at %><br> Posted by <%= feedback.author %> </em> <p> <%= feedback.message %> </p> <hr> </article>
Define the necessary routes:
# config/routes.rb Rails.application.routes.draw do resources :feedbacks root 'static_pages#index' end
Adding navigation and layout updates
Add a global menu to the layout for easy navigation:
<!-- views/layouts/application.html.erb --> <!-- other code goes here... --> <nav> <ul> <li><%= link_to 'Home', root_path %></li> <li><%= link_to 'Feedback', new_feedback_path %></li> </ul> </nav>
Also, tweak the layout to include the charset and display the current locale:
<html lang="<%= I18n.locale %>"> <head> <meta charset="utf-8"> <!-- ... other code ... ->
Final setup for Rails i18n
Run migrations and start the Rails server:
rails db:migrate rails s
Navigate to http://localhost:3000
to ensure everything is working correctly.
Now that we have a basic app up and running, let’s proceed to the main part—localizing our application!
A bit of Rails I18n configuration
Before we start translating, we need to decide which languages our application will support. You can pick any, but for this tutorial, we’ll use English (as the default) and French. Update the config/application.rb
file to reflect this:
# ... config.i18n.available_locales = %i[en fr] config.i18n.default_locale = :en
Also hook up a rails-i18n gem, which has locale data for different languages. For example, it has translated month names, pluralization rules, and other useful stuff.
To simplify things, we’ll use the rails-i18n gem. This gem provides locale data for various languages, such as translated month names, pluralization rules, and other helpful configurations.
Add the gem to your Gemfile
:
# Gemfile # ... gem 'rails-i18n', '~> 8'
Then install the library:
bundle install
That’s it! Now we have everything set up to start adding translations.
Storing translations for Rails i18n
Now that everything is configured, let’s start translating the home page content.
Localized views
The simplest way to add translations is by using localized views. This involves creating separate view files for each supported language. The naming convention is:
index.LOCALE_CODE.html.erb
Here, LOCALE_CODE
corresponds to the language code. For this demo, we’ll create two views:
index.en.html.erb
for Englishindex.fr.html.erb
for French
Add the localized content to these files:
<!-- views/static_pages/index.en.html.erb --> <h1>Welcome!</h1> <p>We provide some fancy services to <em>good people</em>.</p>
<!-- views/static_pages/index.fr.html.erb --> <h1>Bienvenue !</h1> <p>Nous offrons des services fantastiques aux <em>bonnes personnes</em>.</p>
Rails will automatically render the correct view based on the currently set locale. Handy, right?
However, while localized views work well for small projects or static pages, they aren’t always practical—especially when you have a lot of dynamic content. For that, we use translation files.
Translation files for Rails i18n
Instead of hardcoding text into views, Rails allows you to store translated strings in separate YAML files under the config/locales
directory. This is the recommended approach for managing translations at scale.
Open the config/locales
folder, and you’ll notice a default en.yml
file with some sample data:
en: hello: "Hello world"
Here:
en
is the top-level key, representing the language code.- The
hello
key maps to the translated string"Hello world"
. Just make sure to properly organize translation keys for your own convenience.
Let’s replace the sample data with a welcome message for the home page. Update en.yml
like this:
# config/locales/en.yml en: welcome: "Welcome!"
Now, create a new fr.yml
file in the same directory to add the French translation:
# config/locales/fr.yml fr: welcome: "Bienvenue !"
With these files, you’ve successfully created translations for your first string—great!
Performing simple translations with Rails I18n
Now that we’ve populated the YAML files with some data, let’s see how to display those translated strings in the views. It’s as simple as using the translate
method (aliased as t
). This method requires just one argument—the name of the translation key:
<!-- views/static_pages/index.html.erb --> <h1><%= t 'welcome' %></h1>
When the page is rendered, Rails looks up the string that corresponds to the provided key and displays it. If the translation isn’t found, Rails will render the key itself (and format it into a more human-readable form).
Naming and organizing translation keys
Translation keys can be named almost anything, but it’s always a good idea to use meaningful names that describe their purpose. Additionally, it’s best to keep your keys well-organized—you can refer to best practices in the Translation keys: naming conventions and organizing blog post for more detailed guidelines.
Adding the second message
Let’s add the next string to both en.yml
and fr.yml
:
# config/locales/en.yml en: welcome: "Welcome!" services_html: "We provide some fancy services to <em>good people</em>."
# config/locales/fr.yml fr: welcome: "Bienvenue!" services_html: "Nous fournissons des services sophistiqués aux <em>bonnes personnes</em>."
Why the _html
postfix?
You may have noticed the _html
postfix on the translation key. Here’s why:
By default, Rails will escape any HTML tags, rendering them as plain text. Since we want the <em>
tag to remain intact and be displayed as formatted HTML, we mark the string as “safe HTML” using this convention.
Using the t
method again
Now, let’s render the second message in the view:
<!-- views/static_pages/index.html.erb --> <!-- ... ---> <p><%= t 'services_html' %></p>
And that’s it! Rails will display the proper translation values based on the current locale, and your HTML formatting will remain untouched.
More on translation keys and Rails i18n
Our homepage is now localized, but let’s pause and consider what we’ve done so far. While our translation keys have meaningful names, what happens as our application grows? For example, imagine we have 500+ messages—not an unrealistic number for even a small app. Larger websites can easily have thousands of translations.
If all the keys are stored directly under the en
key with no further organization, this leads to two main problems:
- Ensuring all keys have unique names becomes increasingly difficult.
- It’s hard to locate related translations, such as those for a specific page or feature.
Grouping translation keys
To avoid these issues, it’s a good idea to group your keys logically. For example, you can nest your translations under arbitrary keys:
en: main_page: header: welcome: "Welcoming message goes here"
There’s no limit to the nesting levels (though it’s best to keep it reasonable). This also allows you to use identical key names in different groups without any conflicts.
Following the view folder structure
A useful approach is to mirror the folder structure of your views in your YAML files. This makes your translations easy to navigate and aligns naturally with Rails conventions. Let’s reorganize our existing translations like so:
English:
# config/locales/en.yml en: static_pages: index: welcome: "Welcome!" services_html: "We provide some fancy services to <em>good people</em>."
And French:
# config/locales/fr.yml fr: static_pages: index: welcome: "Bienvenue!" services_html: "Nous fournissons des services sophistiqués aux <em>bonnes personnes</em>."
Referencing translations
To access these nested keys, you provide the full path to the key in the t
helper methods:
<!-- views/static_pages/index.html.erb --> <h1><%= t 'static_pages.index.welcome' %></h1> <p><%= t 'static_pages.index.services_html' %></p>
However, Rails provides a shortcut called lazy lookup. If you are referencing translations in a view or controller, and the keys are namespaced properly (following the folder structure), you can omit the full path. Instead, use a leading dot (.
):
<!-- views/static_pages/index.html.erb --> <h1><%= t '.welcome' %></h1> <p><%= t '.services_html' %></p>
The leading dot tells Rails to look for the translation key relative to the current namespace—in this case, static_pages.index
.
Translating the global menu
Next, let’s localize our global menu and namespace these translations properly:
# config/locales/en.yml en: global: menu: home: "Home" feedback: "Feedback"
# config/locales/fr.yml fr: global: menu: home: "Page d'accueil" feedback: "Retour"
Since the global menu is in the layout file, we can’t use lazy lookup here. Instead, provide the full path to the keys:
<!-- views/layouts/application.html.erb --> <!-- ... ---> <nav> <ul> <li><%= link_to t('global.menu.home'), root_path %></li> <li><%= link_to t('global.menu.feedback'), new_feedback_path %></li> </ul> </nav>
Translating models
Now let’s move on to the feedback page and localize the form.
Translating input labels
Rails makes it easy to translate model attributes. By providing the appropriate translations under the activerecord
namespace, Rails will automatically pick them up when generating form labels.
Update your YAML files as follows:
English:
# config/locales/en.yml en: activerecord: attributes: feedback: author: "Your name" message: "Message"
# config/locales/fr.yml fr: activerecord: attributes: feedback: author: "Votre nom" message: "Message"
With these translations in place, Rails will automatically display the localized labels for the author and message fields.
Translating the submit button
Rails also allows you to provide translations for the model name, which are used to generate button text like “Create Feedback.” For example:
# config/locales/en.yml en: activerecord: models: feedback: "Feedback"
This would make Rails render the button text as “Create Feedback”.
However, if you prefer a generic “Submit” button, you can define it in a global namespace instead:
# config/locales/en.yml en: global: forms: submit: "Submit"
# config/locales/fr.yml fr: global: forms: submit: "Soumettre"
Now, update the submit button in your form partial to use this translation:
<!-- views/feedbacks/_form.html.erb --> <!-- ... ---> <%= form.submit t('global.forms.submit') %>
Error messages
We don’t want users to submit empty feedback, so let’s add some simple validations to the Feedback
model:
# models/feedback.rb # ... validates :author, presence: true validates :message, presence: true, length: { minimum: 5 }
Translating error messages
But what about the corresponding error messages? How do we translate them? The rails-i18n
gem includes localized error messages for many languages, so you don’t need to do anything extra for common validation errors.
For example, if you’re using the French locale, Rails will automatically render the appropriate error messages. If you want to customize these default messages further, refer to the official Rails I18n guide.
One problem with the form, however, is that the error messages subtitle (the one that says “N errors prohibited this feedback from being saved:”) is not translated. Let’s fix it now and also discuss pluralization.
Pluralization rules in Rails i18n
Since the feedback form may display one or more error messages, the word “error” in the subtitle needs to be pluralized correctly. In English, this is usually done by adding an “s” (e.g., error → errors), but in other languages, pluralization rules can be more complex.
Luckily, the rails-i18n
gem handles all the pluralization rules for supported languages, so you don’t have to write them yourself.
English and French pluralization
For both English and French, there are just two pluralization cases:
- one – Singular (e.g., “1 error”).
- other – Plural (e.g., “2 errors”).
Here’s how you define them in the translation files:
English:
# config/locales/en.yml en: global: forms: submit: "Submit" messages: errors: one: "One error prohibited this form from being saved:" other: "%{count} errors prohibited this form from being saved:"
French:
# config/locales/fr.yml fr: global: forms: submit: "Soumettre" messages: errors: one: "Une erreur a interdit l'enregistrement de ce formulaire :" other: "%{count} erreurs ont empêché la sauvegarde de ce formulaire :"
Here, %{count}
is used for interpolation—Rails inserts the value of count
into the string wherever %{count}
appears.
Handling complex pluralization rules with Rails i18n
If you’re working with a language that has more than two plural forms, you need to provide additional keys. For example, Russian has four plural forms:
# config/locales/ru.yml ru: global: forms: messages: errors: one: "Найдена одна ошибка:" few: "Найдены %{count} ошибки:" many: "Найдено %{count} ошибок:" other: "Найдена %{count} ошибка:"
Here’s what each key means:
- one: Used for 1 (e.g., одна ошибка).
- few: Used for 2–4 (e.g., две ошибки).
- many: Used for 5 and higher (e.g., пять ошибок).
- other: Catch-all, sometimes for 0 or edge cases.
Using translations in the form
Now let’s use these translations in the feedback form. Update the feedbacks/_form.html.erb
partial like this:
<%= form_with(model: feedback) do |form| %> <% if feedback.errors.any? %> <div style="color: red"> <h2><%= t 'global.forms.messages.errors', count: feedback.errors.count %></h2> <ul> <% feedback.errors.each do |error| %> <li><%= error.full_message %></li> <% end %> </ul> </div> <% end %> <!-- ... other code ... --> <% end %>
- The
t
method fetches the correct translation based on the key and the count variable. - Rails selects the appropriate plural form (
one
,other
,few
, ormany
) based on thecount
. - The
%{count}
placeholder is dynamically replaced with the actual number of errors.
For example:
- With 1 error, the English subtitle will read: “One error prohibited this form from being saved.”
- With 3 errors, it will display: “3 errors prohibited this form from being saved.”
Working with date and time
Now let’s localize the _feedback.html.erb
partial. We need to translate two pieces of text:
- The date and time (the
created_at
field). - The “Posted by…” string, which includes the author’s name.
Translating “Posted by…” with interpolation
For the “Posted by…” string, we’ll use interpolation to include the author’s name dynamically. Add the following translations to your YAML files:
# config/locales/en.yml en: feedbacks: posted_by: "Posted by %{author}"
# config/locales/fr.yml fr: feedbacks: posted_by: "Envoyé par %{author}"
Using translations in the view
Now, update the partial to include the t
method for the “Posted by…” text:
<!-- views/feedbacks/_feedback.html.erb --> <%= tag.article id: dom_id(feedback) do %> <em> <%= tag.time feedback.created_at, datetime: feedback.created_at %><br> <%= t 'feedbacks.posted_by', author: feedback.author %> </em> <p> <%= feedback.message %> </p> <hr> <% end %>
Here, the t
method fetches the appropriate translation and interpolates the author value dynamically.
Localizing date and time
To handle the created_at
timestamp, we’ll use the localize
method (aliased as l
). This works similarly to Ruby’s strftime
but automatically uses translated month and day names based on the current locale.
Let’s use the predefined :long
format:
<%= tag.article id: dom_id(feedback) do %> <em> <%= tag.time l(feedback.created_at, format: :long), datetime: feedback.created_at %><br> <%= t 'feedbacks.posted_by', author: feedback.author %> </em> <p> <%= feedback.message %> </p> <hr> <% end %>
With the l
method:
- Rails automatically uses the current locale to determine the translation for month names, days, etc.
- The
:long
format is a built-in Rails format that provides a user-friendly, full-length date.
For example:
- English: “March 15, 2024 12:30 PM”
- French: “15 mars 2024 12:30”
Defining custom date and time formats
If you need a specific date or time format, you can define your own. Add a custom format under the time.formats
namespace:
# config/locales/en.yml en: time: formats: custom_short: "%H:%M"
You can then use it in your view like this:
<%= l(feedback.created_at, format: :custom_short) %>
This would render only the time, e.g., “12:30”.
Switching between locales in Rails i18n
Now that our app is fully translated, there’s just one problem: we can’t change the locale. While it might sound minor, it’s actually a major usability issue—so let’s fix it!
We’ll implement the following solution:
- URLs will include an optional
:locale
parameter, like:http://localhost:3000/en/some_page
. - If this parameter is set and matches a supported locale, Rails will switch to that language.
- If no parameter is provided or the locale isn’t supported, Rails will fall back to the default locale (English).
Sounds simple? Let’s dive into the code.
Routes
First, update the config/routes.rb
file to include a locale scope:
# config/routes.rb scope "(:locale)", locale: /#{I18n.available_locales.join("|")}/ do # your routes here... resources :feedbacks root 'static_pages#index' end
Here:
- The
(:locale)
scope makes the:locale
parameter optional. - We validate the locale using a regular expression to ensure it matches one of the supported locales.
Adding a concern for locale handling
Next, create an internationalization concern to handle locale switching.
Include the concern in ApplicationController
:
# controllers/application_controller.rb class ApplicationController < ActionController::Base include Internationalization end
Create this new concern:
# controllers/concerns/internationalization.rb module Internationalization extend ActiveSupport::Concern included do around_action :switch_locale private def switch_locale(&action) locale = locale_from_url || locale_from_headers || I18n.default_locale response.set_header 'Content-Language', locale I18n.with_locale(locale, &action) end def locale_from_url locale = params[:locale] return locale if I18n.available_locales.map(&:to_s).include?(locale) end def locale_from_headers header = request.env['HTTP_ACCEPT_LANGUAGE'] return if header.nil? locales = parse_header(header) return if locales.empty? detect_from_available(locales) end def parse_header(header) header.gsub(/\s+/, '').split(',').map do |tag| locale, quality = tag.split(/;q=/i) quality = quality ? quality.to_f : 1.0 [locale, quality] end.reject { |(locale, quality)| locale == '*' || quality.zero? } .sort_by { |(_, quality)| quality } .map(&:first) end def detect_from_available(locales) locales.reverse.find { |l| I18n.available_locales.any? { |al| match?(al, l) } } end def match?(str1, str2) str1.to_s.casecmp(str2.to_s).zero? end def default_url_options { locale: I18n.locale } end end end
How it works
switch_locale
: Determines the locale by checking:- URL parameters (
locale_from_url
). - Request headers (
locale_from_headers
). - Defaults to
I18n.default_locale
if none are found.
- URL parameters (
default_url
_
options
: Ensures all Rails URL helpers include the:locale
parameter automatically.
The parse_header
method handles the complexity of parsing the HTTP_ACCEPT_LANGUAGE
header, which browsers send to indicate preferred languages.
Adding locale switching links
Now, let’s allow users to switch locales from the UI. Update the global menu in application.html.erb
:
<!-- views/layouts/application.html.erb --> <ul> <% I18n.available_locales.each do |locale| %> <li> <% if I18n.locale == locale %> <%= t(locale, scope: 'locales') %> <% else %> <%= link_to t(locale, scope: 'locales'), url_for(locale: locale) %> <% end %> </li> <% end %> </ul>
Here:
I18n.available_locales
provides a list of supported locales.t(locale, scope: 'locales')
fetches the translated name of the locale.url_for(locale: locale)
generates a URL with the updated:locale
parameter.
Adding locale translations
Add translations for the locale names:
# config/locales/en.yml en: locales: en: "English" fr: "French"
And French:
# config/locales/fr.yml fr: locales: en: "Anglais" fr: "Français"
Testing Rails i18n out
- Restart your Rails server.
- Visit URLs like:
http://localhost:3000/en
→ App will render in English.http://localhost:3000/fr
→ App will render in French.
- Switch between locales using the links in the navigation menu.
Rails will:
- Remember the locale in URLs.
- Automatically include the current locale in all generated links.
- Fallback to the default locale if no valid
:locale
is provided.
Simplify your Rails i18n workflow with Lokalise
By now, you might be thinking that supporting multiple languages on a large website is a pain. Honestly? You’re right. While Rails makes it easier with namespaced keys and multiple YAML files, it’s still on you to ensure every key is translated across all locales.
Fortunately, there’s a better way: Lokalise—a platform that simplifies managing and editing localization files. Let’s walk through the setup.
Initial Lokalise setup
- Get started: Sign up for a free trial on Lokalise.
- Generate your API token:
- Go to your personal profile → API tokens → generate a read/write token.
- Create a new project:
- Go to Lokalise and create a “Web and mobile” project.
- Name it anything you like, set English as the base language, and add French (or your target language).
- Find your project ID: On the project page, click “More” → “Settings.” The project ID will be listed here.
- Use Lokalise CLI tool to facilitate file exchange.
Using the lokalise_rails
gem
Prefer a more Rails-like experience instead of relying on command-line interface? Enter the lokalise_rails gem, which integrates Lokalise directly into your app.
Installation
Add the gem to your Gemfile
:
gem 'lokalise_rails'
Then run:
bundle install rails g lokalise_rails:install
Configuration
Update config/lokalise_rails.rb
with your token and project ID:
require 'lokalise_rails' LokaliseRails::GlobalConfig.config do |c| c.api_token = ENV['LOKALISE_API_TOKEN'] c.project_id = ENV['LOKALISE_PROJECT_ID'] # ... other options ... end
Here you’ll need to enter your Lokalise API and the project ID.
The import and export options have sensible defaults, but you may tweak them further as necessary.
Exporting and importing translations
Use these commands to manage translations:
Export to Lokalise:
rails lokalise_rails:export
Import back into your app:
rails lokalise_rails:import
Advanced: Managing multiple directories in Rails i18n
If your app uses custom directories for translations, you can leverage the LokaliseManager gem to handle this programmatically. For example:
require 'rake' require 'lokalise_rails' require "#{LokaliseRails::Utils.root}/config/lokalise_rails" namespace :lokalise_custom do task :export do # importing from the default directory (./config/locales/) exporter = LokaliseManager.exporter({}, LokaliseRails::GlobalConfig) exporter.export! # importing from the custom directory exporter = LokaliseManager.exporter({locales_path: "#{Rails.root}/config/custom_locales"}, LokaliseRails::GlobalConfig) exporter.export! rescue StandardError => e abort e.inspect end end
This script uploads all YAML files from both config/locales
and config/custom_locales
.
Conclusion to Rails i18n
In this article, we’ve explored how to:
- Set up Rails I18n for a multilingual app.
- Use translation files and localized views.
- Translate error messages, model attributes, and dynamic content.
- Switch between locales with URL-based persistence.
- Simplify your workflow with Lokalise and its tools.
While Rails I18n is powerful, tools like Lokalise make managing translations at scale a breeze. For further details, I recommend checking out the official Rails I18n guide.
Thank you for staying with me, and until next time!