A Comprehensive Guide to Rails Internationalization (i18n)

Internationalization means adapting your application to the language and culture of your users—a difficult task! Luckily, Rails provides the I18n API. In this article, Pavel Tkachenko shows us how to start translating.

Thanks to the Internet, our big Earth has suddenly become a small place, in the sense that your customers on the other side of the world are now as close to you as your next door neighbor. However, the solution to the problem of distance is not everything. We have become closer, but we continue to communicate in different languages.

In addition to language barriers, there is also a cultural implication. We understand the same things in different ways; we have different systems of measurement and understanding of time. For example, in China, people understand 02.03.2021 as March 2nd, while in the USA, it is February 3rd. Also, Rails is very conventional about the app structure unless we are talking about translations, and we are going to fix this problem. Internationalization solves this problem, but what exactly is internationalization?

Internationalization is a development technique that simplifies the adaptation of a product to the linguistic and cultural differences of a region other than the one in which the product was developed. Simply put, it is an opportunity to stay on the same page with people of other languages and cultures.

Can Rails help us solve the problem of communicating with customers around the world? It sure can! Rails has a built-in library, I18n, which is a powerful internationalization tool. It allows translations to be organized, supports pluralization, and has good documentation. The problem is that when it comes to internationalization, Rails does not follow its "Convention over Configuration" principle, and the developer is left to solve the problem of organizing the code and choosing techniques for implementing internationalization.

We'll be using Rails 6+, Ruby 3+, and postgresql 10+ for this guide, although all the techniques you'll see should work with earlier versions just fine. We will create a simple multi-lingual app – a product catalog. It will have both public and admin parts. Let's start by initializing our app. Also, we will replace minitest with rspec, so add a -T flag.

rails new global_market --database=postgresql -T

Next, we will add some configuration to increase our productivity. Just create i18n.rb file in config/initializers and add everything you need.

# config/initializers/i18n.rb

# With this line of code, we can use any folder structure for our
# translations in the config/locales directory
I18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.yml")]

# Locales are supported by our app 
I18n.available_locales = %i[en es]

# Our default locale
I18n.default_locale = :en

An app can't exist without a welcome page and title, so we will create them now.

# config/routes.rb
root to: "site/welcome#index"

namespace :site, path: "/" do
  root to: "welcome#index"
end

As you can see, we have extracted the public part of our app to the site namespace. It will help us organize translations properly for the whole public part.

Now, we need a controller to render our welcome page. Following our route structure, we will create a separate folder for all site related controllers.

# app/controllers/site/base_controller.rb

module Site
  # All controllers in Site namespace should inherit
  # from this BaseController
  class BaseController < ApplicationController
    layout "site"
  end
end

# app/controllers/site/welcome_controller.rb
module Site
  class WelcomeController < BaseController
    def index; end
  end
end

In Site::BaseController there is an instruction to use site layout in all controllers which inherits it. Thus, our Site::WelcomeController uses the site layout because it inherits from Site::BaseController.

Implementing namespaces and base controllers is a good practice for not only organizing layouts and translations but also scoping business logic, authentication, and authorization in one place. You will see how it works with the admin's part.

Now, we need to create layout and welcome page.

<!-- app/views/layouts/site.html.erb -->
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
  <head>
    <title>Global market</title>

    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

The html tag here has a very important attribute called lang. You can find a more detailed description at w3.org, but in this post, we will focus on Rails. We set lang to our current app locale.

<!-- app/views/site/welcome/index.html.erb -->
<h1>Global market</h1>
<p><%= t(".greet") %></p>

The t helper here is an alias for translate, which looks up the translation for a given path. We provide ".greet" as an argument, and the leading dot here says that translation lookup should be relative. In our case, I18n parses it like it is site.welcome.index.greet. Smart enough! All we need is to add translation files.

# config/locales/views/site/welcome/index/index.en.yml
en:
  site:
    welcome:
      index:
        greet: "Best prices and fast delivery"

# config/locales/views/site/welcome/index/index.en.yml
es:
  site:
    welcome:
      index:
        greet: "Mejores precios y entrega rápida."

Our current file structure is very convenient to work with.

\locales
  \views
    \site
      \welcome
        \index
          index.en.yml
          index.es.yml

IDE search example

Don't forget to restart your Rails server because translation files are loaded once during app initialization. If you need your Rails app to detect new locales/*.yml files automatically without restarting it, add simple script to your initializers.

I18n expects that the root of our translation file is a language identifier, and further hierarchy information is irrelevant. Here, we have a hierarchy that corresponds to our translation lookup path. Open your index page, and you should see this text: "Best prices and fast delivery".

Locale detection strategy

We have two languages now, but how do we show the appropriate one to our customers? There are lots of strategies, but for the public part (site), we will detect the user’s language from the URL, as Google recommends.

All our routes for the site namespace will look like this:

/en
/en/products
/en/products/1

/es
/es/products
/es/products/1

Looks beautiful, eh? But, how do we inject the locale into all routes and links? Let's start with our routes; we will change them a little bit.

# config/routes.rb
root to: "site/welcome#index" 

namespace :site, path: "/" do
  # Now we have params[:locale] available
  # in our controllers and views
  scope ":locale"
    root to: "welcome#index"
  end
end
# app/controllers/site/base_controller.rb

module Site
  class BaseController < ApplicationController
    layout "site"

    before_action :set_locale

    private

    def set_locale
      if I18n.available_locales.include?(params[:locale]&.to_sym)
        I18n.locale = params[:locale]
      else
        redirect_to "/#{I18n.default_locale}"
      end
    end
  end
end

Now, if a user opens the index page, Rails redirects it to /en, because we set English as our default locale. When users try to set a non-existing locale in the URL, Rails also redirects them to /en. Such an approach is great for URL sharing and search engines and looks pretty. For completeness, let's add a language switch button in the site layout.

  <!-- app/views/layouts/site.html.erb -->
  <!-- <head>...</head> -->
  <body>
    <ul>
      <% I18n.available_locales.each do |locale| %>
        <% if I18n.locale != locale %>
          <li>
            <%= link_to t(".lang.#{locale}"), url_for(locale: locale) %>
          </li>
        <% end %>
      <% end %>
    </ul>

    <%= yield %>
  </body>

How does it work? We iterate all available locales, and if it is not equal to the current locale, the link is rendered. The link preserves the current URL and only the locale changes.

Translation for static pages

Often, there are a lot of rich-text pages (e.g., About and Terms of Service pages), and using the translate method everywhere to creating corresponding translation files is inconvenient. However, Rails has built-in functionality for such cases. Let's create an about page to check it out. We need to add the route and action first.

# config/routes.rb
scope ":locale"
  root to: "welcome#index"
  get "about", to: "welcome#about" # add route to /about page
end

# app/controllers/site/welcome_controller.rb
module Site
  class WelcomeController < BaseController
    def index; end

    def about; end  # add action to render appropriate view
end

Next, add about.en.html.erb and about.es.html.erb files to views/site/welcome. As you can see, there is a locale identifier in the file name. Each file can have its own text, styles, and photos. The key point here is that Rails renders views based on the user’s current locale.

<!-- app/views/site/welcome/about.en.html.erb -->
<%= image_tag("https://lipis.github.io/flag-icon-css/flags/4x3/gb.svg", width: "100px") %>
<h1>About the project</h1>

<!-- app/views/site/welcome/about.es.html.erb -->
<%= image_tag("https://lipis.github.io/flag-icon-css/flags/4x3/es.svg", width: "100px") %>
<h1>Sobre el proyecto</h1>

Now, when a user opens /en/about or es/about, Rails renders the appropriate view template. At this point, our site guests can visit the index and about pages and change the language. Next, let's add some dynamic data!

Admins

We need to create an /admins part, where our admins will be able to add products to the catalogue. They might speak different languages, so it's a good idea to make the UI multi-lingual for them, too. While in the site part, we use URL params to select the language, here, we have admin in the database, and it's a good idea to derive the locale from an admin entry in the DB. We will also add the devise gem for authentication purposes.

# Gemfile
gem "devise"

Install the devise gem and set the initial configuration:

bundle instal
rails generate devise:install

Now, we are ready to create our Admin model.

rails generate devise Admin

Open the migration file that Rails just created and add an additional field, locale, to it. We will use it to select a language for the UI when an admin signs in.

# ...

t.string :email,              null: false, default: ""
t.string :encrypted_password, null: false, default: ""

t.string :locale,             null: false, default: "en" # add this line

# ...

Don't forget to run migrations!

rails db:migrate

Devise automatically injects devise routes for admin, but we also need an admins namespace where all the authentication-check magic happens.

# config/routes.rb
devise_for :admins # this is added by Devise

namespace :admins do
  root to: "welcome#index"
end

As you can see, in the admins namespace, like in site namespace, we have a reference to the welcome controller. The cool thing here is that they do not conflict. For the site namespace, we will create BaseController and WelcomeController.

# app/controllers/admins/base_controller.rb

module Admins
  # All controllers in the Admins namespace should inherit
  # from this BaseController
  class BaseController < ApplicationController
    layout "admins"

    # Devise will ask for authentication for
    # all inherited controllers
    before_action :authenticate_admin!

    before_action :set_locale

    private

    # When admins are authenticated, we can access their data
    # via the current_admin helper and obtain their locale from DB
    def set_locale
      I18n.locale = current_admin.locale
    end
  end
end

# app/controllers/admins/welcome_controller.rb
module Admins
  class WelcomeController < BaseController
    def index; end # Only admin is able to visit this page
  end
end

Now, we need corresponding views and translation files.

<!-- app/views/layouts/admins.html.erb -->
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
  <head>
    <title>Admins only</title>

    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

<!-- app/views/admins/welcome/index.html.erb -->
<h1><%= t(".languages", count: I18.available_locales.length)%></h1>
# config/locales/views/site/welcome/index/index.en.yml
en:
  admins:
    welcome:
      index:
        languages:
          zero: "There are no languages available"
          one: "Only one language is available"
          other: "There are %{count} languages available"

# config/locales/views/site/welcome/index/index.en.yml
es:
  admins:
    welcome:
      index:
        languages:
          zero: "No hay idiomas disponibles"
          one: "Solo hay un idioma disponible"
          other: "Hay %{count} idiomas disponibles"

Everything else is set up like site/welcome, but here, we introduce pluralization for countable things. I18n is able to select the appropriate translation based on the variable passed to it. If you see that pluralization is not working well for your locale, it's a good idea to enhance your Rails app with the rails-i18n gem.

To see the result, we have to add an admin entity. Forms and views for this entity are outside the scope of this topic, so use the console to add an admin to the database.

rails c
Admin.create(email: "admin@dev.me", password: "12345678", locale: "en")

Now, when you visit the /admins path, devise asks for your credentials. If the credentials are correct, you will gain access to the /admins root path and translations based on the locale extracted from the database entry.

ActiveRecord translations

Finally, we are ready to add a Product model with the ability to add dynamic translation. First, we will use the mobility gem. Mobility is a gem for storing and retrieving translations as attributes on a class. It has several strategies for storing translations and works perfectly with both ActiveRecord and Sequel.

# Gemfile
gem "mobility"

Don't forget to install and add migrations provided by the mobility gem. It will create two separate tables for storing string and text translations, respectively. It's a default translation storing strategy, but you are free to check out other options.

bundle install
rails generate mobility:install
rails db:migrate

Mobility created the initializer config/initializers/mobility.rb. Go there and uncomment plugin locale_accessors. More information about locale accessors is provided here.

Our product has a non-translatable title and a translatable description. Also, we want to add price and sales_start_at fields, which will be localized appropriately. Description should not be present in migration because it will be handled by mobility tables.

rails g model Product title:string price:integer sales_start_at:datetime

To make a translatable description available for the model, we just need to connect the model to mobility and specify the field and type via a special DSL.

# models/product.rb
class Product < ApplicationRecord
  extend Mobility
  # We want description_en and description_es available to read and write
  # and store it in text translations table
  translates :description, type: :text, locale_accessors: I18n.available_locales
end

Let's continue with all the routes, controllers, and view routines.

# config/routes.rb

namespace :admins do
  root to: "welcome#index"
  resources :products # add routes for products
end

As you can see, controllers can also handle relative translation lookups. For our create action, we have two translations: when a product is created successfully or a failure occurs. For t(".success"), i18n looks for admins.products.create.success, and for t(".failure"), the path will be admins.products.create.failure.

# app/controllers/admins/products_controller.rb
module Admins
  class ProductsController < BaseController
    def index
      @products = Product.all
    end

    def show
      @product = Product.find(params[:id])
    end

    def new
      @product = Product.new
    end

    def create
      @product = Product.new(product_params)
      if @product.save
        flash[:success] = t(".success")
        redirect_to [:admins, @product]
      else
        flash[:error] = t(".failure")
        render :new
      end
    end

    private

    # gem `mobility` creates an accessor method for translatable fields, and now we have `description_en` and
    # `description_es`. This is why we include them in the permitted list.
    def product_params
      params.require(:product).permit(:title, :description, :price, :sales_start_at)
    end
  end
end

Next, we will create corresponding translations for our controller. Pay attention to the locale files structure; the final one is available at the end of this post.

# config/locales/controllers/admins/products/products.en.yml
en:
  admins:
    products:
      create:
        success: "Product has been added successfully"
        failure: "Check the form"

# config/locales/controllers/admins/products/products.es.yml
es:
  admins:
    products:
      create:
        success: "El producto se ha agregado con éxito"
        failure: "Consultar el formulario"

Finally, we’ll add views to work with our products. I will not show translation files here because the logic is similar to the welcome page, as shown above, so give it a shot. You can read about Model.human_attribute_name(:attribute) here.

<!-- app/views/admins/products/index.html.erb -->
<h1><%= t(".title") %></h1>
<table>
  <thead>
      <tr>
        <th><%= Product.human_attribute_name(:title) %></th>
        <th><%= Product.human_attribute_name(:price) %></th>
        <th><%= Product.human_attribute_name(:sales_starts_at) %></th>
      </tr>
  </thead>
  <% @products.each do |product| %>
    <tr>
      <td><%= link_to product.title, [:admins, product] %></td>
      <td><%= product.price %></td>
      <td><%= product.sales_starts_at %></td>
    </tr>
  <% end %>
</table>
<!-- app/views/admins/products/new.html.erb -->
<h1><%= t(".title") %></h1>
<%= form_for([:admins, @product]) do |f|>
  = f.label :title
  = f.text_field :title
  <br>
  = f.label :price
  = f.number_field :price
  <br>
  = f.label :sales_starts_at
  = f.date_time :sales_starts_at
  <br>
  <% I18n.available_locales.each do |locale| %>
    = f.label "description_#{locale}"
    = f.text_area "description_#{locale}"
  <% end %>
<% end %>

Localize

To understand how to display language and culturally sensitive data, such as dates and numbers, let's finish our site index page. Rails I18n has the built-in helper l (alias of localize). It helps render DateTime properly, according to the current language.

<% @products.each do |product| %>
  <tr>
    <td><%= product.title %></td>
    <td><%= product.price %></td>
    <td><%= l(product.sales_starts_at, format: :short) %></td>
  </tr>
<% end %>

If you need more precise control over the display of dates and times, I recommend delegating such functionality to JavaScript by adding the date-fns library. Just create a helper and bypass data to this library like we will do with number rendering.

Although localize works pretty well with the date and time, there are lingual differences in how numbers (e.g., prices) are rendered. To resolve this problem, we will create a view helper.

Numbers

We need a little bit of JavaScript here. We need a built-in toLocaleDateString method. I assume that you are using the newest Rails version with Webpacker installed and that ES6 is available for you.

Let's start with the view helper. Create a localize_helper.rb file and then add thelocalize_time method.

# app/helpers/localize_helper.rb
module LocalizeHelper
  def price(number)
    # Render <span data-localize="price">199.95</span>
    content_tag(:span, datetime, data: { localize: :price })
  end
end

Hence, when you add <%= price(@product.price) %> in views, Rails renders the span tag with a data attribute called "localize". We will use this attribute to find a span on the page and display all data properly for a specific language.

// app/javascript/src/helpers/price.js
// Turbolinks are enabled by default in Rails,
// we need to process our script on every page load
// https://github.com/turbolinks/turbolinks#full-list-of-events
document.addEventListener("turbolinks:ready", () => {
  // Get language from html tag
  const lang = document.documentElement.lang;
  // Find all span tags with data-localize="price"
  const pricesOnPage = document.querySelectorAll("[data-localize=\"price\"]");

  if (pricesOnPage.length > 0) {
    // Iterate all price span tags
    [...pricesOnPage].forEach(priceOnPage => {
      // Modify text in span tag according to current language
      priceOnPage.textContent = priceOnPage.textContent.toLocaleString(
        lang, { style: "currency", currency: "USD" }
      );
    })
  }
});

Now, we need to connect this script to our site layout. We want to separate all concerns to keep the code clean. We don't need an existing application JavaScript pack; we want the site JavaScript pack. Just rename it.

mv app/javascript/packs/application.js app/javascript/packs/site.js
// app/javascript/packs/site.js
import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"

// Add this line
import "../src/helpers/price";

Rails.start()
Turbolinks.start()
ActiveStorage.start()

The final step is to connect our site pack to the layout. Just add one line to your site layout in the <head> tag.

<%= javascript_pack_tag 'site', 'data-turbolinks-track': 'reload' %>

Now, you are ready to use your helper, and it will handle prices properly:

<% @products.each do |product| %>
  <tr>
    <td><%= product.title %></td>
    <td><%= price(product.price) %></td> <!-- Modify this line -->
    <td><%= l(product.sales_starts_at, format: :short) %></td>
  </tr>
<% end %>

Maintainable translations

It's very difficult to maintain translations, especially when there are a lot of languages. The must-have tool for every multi-lingual app is https://github.com/glebm/i18n-tasks. 18n-tasks helps you find and manage missing and unused translations. There are a lot of features, some of them are as follows:

  • Find and report translations that are missing or unused.
  • Automatic translation from Google Translate or other services.
  • Integrate tasks with rspec to prevent translations problems on the production server.

i18n-tasks showcase

Conclusion

Congratulations! We have built a simple catalogue that addresses many of the internationalization issues. We learned how to create a convenient file structure for translation files, figured out the translation of static pages, learned strategies for choosing a language, adopted a method to translate dynamic data (ActiveRecord), made our own helpers and, most importantly, understood how to build maintainable multilingual applications.

Final locales file structure

The idea is to follow I18n path logic. Also, it is a very good practice to duplicate the folder and file names in it. It will help to easily find locale files in your IDE/text-editor and edit files in one place.

\models
  \product
    product.en.yml
    product.es.yml
\controllers
  \admins
    \products
      products.en.yml
      products.es.yml
\views
  \layouts
    \site
      site.en.yml
      site.es.yml
    \admins
      admins.en.yml
      admins.es.yml
  \site
    \products
      \show
        show.en.yml
        show.es.yml
      \index
        index.en.yml
        index.es.yml

Honeybadger has your back when it counts.

We combine error tracking, uptime monitoring, and cron & heartbeat monitoring into a simple, easy-to-use platform. Our mission: to tame production and make you a better, more productive developer.

Learn more
author photo

Pavel Tkachenko

Pavel is a web developer involved in all software development processes. He is passionate about UX/UI design and loves both frontend and backend development. He is zealous about teaching and sharing his knowledge with fellow developers.

More articles by Pavel Tkachenko
“We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release.” 
Michael Smith
Try Honeybadger Free for 15 Days
Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
Try Honeybadger Free for 15 Days
Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
Try Honeybadger Free for 15 Days
"Wow — Customers are blown away that I email them so quickly after an error."
Chris Patton
Try Honeybadger Free for 15 Days