A Comprehensive Guide to Rails Internationalization
Tags: ruby, rails, tutorial
Published: 2022-10-21 17:35:15

*This article was originally written by [Pavel Tkachenko](https://www.honeybadger.io/blog/authors/paveltkachenko/) on the [Honeybadger Developer Blog](https://www.honeybadger.io/blog/rails-i18n/).*

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](https://guides.rubyonrails.org/i18n.html). 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.

```bash
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.

```ruby
# 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.

```ruby
# 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.

```ruby
# 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.

```erb
<!-- 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](https://www.w3.org/International/questions/qa-lang-why.en), but in this post, we will focus on Rails. We set `lang` to our current app locale.

```erb
<!-- 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.

```yml
# 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.  

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

![IDE search example](https://www.honeybadger.io/images/blog/posts/rails-i18n/ide-search-example.png)

> 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](https://gist.github.com/pashagray/a90f35465ca5b2377941b335804485b4) 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](https://developers.google.com/search/docs/advanced/crawling/managing-multi-regional-sites?hl=en).

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

```text
/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.

```ruby
# 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
```

```ruby
# 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.

```erb
  <!-- 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.

```ruby
# 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.

```erb
<!-- 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.

```ruby
# Gemfile
gem "devise"
```

Install the devise gem and set the initial configuration:

```bash
bundle instal
rails generate devise:install
```

Now, we are ready to create our `Admin` model.

```bash
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.

```ruby
# ...

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!

```bash
rails db:migrate
```

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

```ruby
# 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`.

```ruby
# 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.

```erb
<!-- 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>
```

```yml
# 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](https://guides.rubyonrails.org/i18n.html#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](https://github.com/svenfuchs/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.

```bash
rails c
```

```ruby
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](https://github.com/shioyama/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.

```ruby
# 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.

```bash
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](https://github.com/shioyama/mobility#-getting-and-setting-translations).

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.

```bash
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.

```ruby
# 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.

```ruby
# 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`.

```ruby
# 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.

```yml
# 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](https://guides.rubyonrails.org/i18n.html#translations-for-active-record-models).

```erb
<!-- 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>
```

```erb
<!-- 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.

```erb
<% @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](https://date-fns.org/) 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`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/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 the`localize_time` method.

```ruby
# 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.

```js
// 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.

```bash
mv app/javascript/packs/application.js app/javascript/packs/site.js
```

```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.

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

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

```erb
<% @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](i18n-task). 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](https://www.honeybadger.io/images/blog/posts/rails-i18n/i18n-tasks.png)

## 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.

```text
\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