Rails internationalization (I18n): Tutorial on Rails locales & more

In this tutorial, you’ll learn how to get started with Rails internationalization (I18n), thus translating an app into multiple languages. You will also learn how to work with translations, localize date and time, and switch locales. We are going to see all these aspects in action by creating a sample application and enhancing it step by step. By the end of the tutorial, you will have all the necessary knowledge to start implementing Rails I18n concepts in real projects.

The source code can be found on GitHub.

 

    Preparing your Rails app for internationalization

    So, as I’ve already mentioned, we are going to see all the relevant concepts in action. Therefore, create a new Rails application by running:

    rails new LokaliseI18n
    

    I’m using Rails 7 for this tutorial, but most of the described concepts apply to older versions as well.

    Now let’s generate a StaticPagesController, which is going to have an index action (our main page):

    rails g controller StaticPages index
    

    Tweak the views/static_pages/index.html.erb view by adding some sample content:

    <h1>Welcome!</h1>
    
    <p>We provide some fancy services to <em>good people</em>.</p>
    

    Also, I would like to add a feedback page where our users will be able to share their (hopefully, positive) opinions on the company. Each piece of feedback will have the author’s name and the actual message:

    rails g scaffold Feedback author message
    

    We’re only interested in two actions: new (which is going to render the form to post a review and also list all the existing reviews) and create (to actually validate and persist the reviews). Of course, ideally the reviews should be pre-moderated, but we won’t bother with this today.

    Tweak the new action to fetch all the reviews from the database and order them by creation date:

    # feedbacks_controller.rb
    # ... 
    def new
      @feedback = Feedback.new
      @feedbacks = Feedback.order created_at: :desc
    end
    

    Additionally, I’d like to redirect the user to the feedback page when the form is processed and the new record is persisted:

    # 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

    Render the feedback collection on the feedbacks/new.html.erb page:

    <!-- views/feedbacks/new.html.erb -->
    <!-- other code goes here... -->
    
    <%= render @feedbacks %>
    

    Lastly, tweak the partial for an individual piece of feedback:

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

    Take care of the routes, like so:

    # config/routes.rb
    
    Rails.application.routes.draw do
      resources :feedbacks
      root 'static_pages#index'
    end
    

    And lastly, add a global menu to the layout:

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

    I’d also recommend setting the charset and displaying the currently set locale, so make some more changes to the layout:

    <head lang="<%= I18n.locale %>">
    <meta charset="utf-8">
    
    <!-- ... other code ... ->

    Now run migrations and boot up the server:

    rails db:migrate
    rails s
    

    Navigate to http://localhost:3000 and make sure everything is fine. Now that we have something to work with, let’s proceed to the main part and localize our application.

    A bit of Rails I18n configuration

    Before translating anything, we need to decide which languages will be supported. You can choose any, but I will stick with French and English, with the latter set as a default. Reflect this in the config/application.rb file:

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

    # Gemfile
    # ... 
    gem 'rails-i18n'
    

    Then, just install this gem and you are good to go:

    bundle install
    

    Storing translations

    Now that everything is configured, let’s work on the home page and translate the text there.

    Localized views

    The simplest way to do this is by utilizing localized views. All you need to do is create views named index.LOCALE_CODE.html.erb,  where the LOCALE_CODE corresponds to one of the supported languages. So, in this demo we should create two views: index.en.html.erb and index.fr.html.erb. Inside them, just place content for the English and French versions of the site, and Rails will automatically pick the proper view based on the currently set locale. Handy, eh?

    This approach, however, is not always feasible.

    Translation files

    Another way would be to store your translated strings in a separate file and render a proper version of the string based on the chosen language. By default, Rails employs YAML files that have to be stored under the config/locales directory. Translations for different languages are stored in separate files, and each file is named after its language.

    Open the config/locales folder and note that there is already an en.yml file in it which has some sample data:

    en:
      hello: "Hello world"
    

    So here, en is a top-level key representing the language code that these translations stored are for. Next, there is a nested key-value pair, where hello is the translation key, and "Hello world" is the actual translated string. Let’s replace this pair with the following content:

    en:
      welcome: "Welcome!"
    

    This is just a welcome message from our homepage. Now create an fr.yml file in the same folder and provide a translated welcome message there as well:

    fr:
      welcome: "Bienvenue!"

    We have just created a translation for our first string — great!

    Performing simple translations with Rails I18n

    Now that we have populated the YAML files with some data, let’s see how to employ the translated strings in the views. It’s actually as simple as utilizing the translate method, which is aliased as t. This method has one required argument — the name of the translation key:

    <!-- views/static_pages/index.html.erb -->
    <h1><%= t 'welcome' %></h1>
    

    When the page is requested, Rails looks up the string that corresponds to the provided key and renders it. If the requested translation cannot be found, Rails will just render the key on the screen (and turn it into a more human-readable form).

    Translation keys can be named anything you like (well, almost anything), but of course it is advised that you give them meaningful names so you can understand what text they correspond to. Also, it’s recommended that you organize your keys properly — you can check this blog post for some instructions and best practices.

    Let’s take care of the second message:

    en:
      welcome: "Welcome!"
      services_html: "We provide some fancy services to <em>good people</em>."
    
    fr:
      welcome: "Bienvenue!"
      services_html: "Nous fournissons des services sophistiqués aux <em>bonnes personnes</em>."

    Why do we need this _html postfix? Well, as you can see our string has some HTML, and by default Rails will render the em tag as plain text. As long as we don’t want this to happen, we mark the string as a “safe HTML”.

    Now just use the t method again:

    <!-- views/static_pages/index.html.erb -->
    <!-- ... --->
    
    <p><%= t 'services_html' %></p>
    

    More on translation keys

    Our homepage is now localized, but let’s stop for a moment and think about what we’ve done. All in all, our translation keys have meaningful names, but what happens if we are going to have, say, 500 messages in the app? This number is actually not that big, and large websites could have thousands of translations.

    If all our key-value pairs are stored right under the en key without any further grouping, this leads to two main problems:

    • We need to make sure that all the keys have unique names. This becomes increasingly complex as your application grows.
    • It is hard to locate all related translations (for example, translations for a single page or feature).

    Therefore, as previously mentioned, it would be a good idea to organize your keys in a meaningful way. For example, you can further group your translations under arbitrary keys and do something like this:

    en: 
      main_page:
        header:
          welcome: "Welcoming message goes here"
    

    The level of nesting is not limited (but you should be reasonable about it), and the keys in different groups may have identical names.

    It is beneficial, however, to follow the folder structure of your views (we’ll see why in a moment). Therefore, tweak the YAML files in the following way:

    English

    en:
      static_pages:
        index:
          welcome: "Welcome!"
          services_html: "We provide some fancy services to <em>good people</em>."
    

    And French:

    fr:  
      static_pages:
        index:
          welcome: "Bienvenue!"
          services_html: "Nous fournissons des services sophistiqués aux <em>bonnes personnes</em>."

    Generally, you need to provide the full path to the translation key when referencing it in the t method:

    <!-- views/static_pages/index.html.erb -->
    
    <h1><%= t 'static_pages.index.welcome' %></h1>
    
    <p><%= t 'static_pages.index.services_html' %></p>
    

    However, there is also a “lazy” lookup available. If you perform the translation in a view or controller, and the translation keys are namespaced properly, following the folder structure, you may omit the namespaces all together. This way, the above code turns into:

    <!-- views/static_pages/index.html.erb -->
    
    <h1><%= t '.welcome' %></h1>
    
    <p><%= t '.services_html' %></p>
    

    Please note that the leading dot is required here.

    Let’s also translate our global menu and namespace these translations properly:

    en:
      global:
        menu:
          home: "Home"
          feedback: "Feedback"
    
    fr:
      global:
        menu:
          home: "Page d'accueil" 
          feedback: "Retour"

    In this case we can’t take advantage of the lazy lookup, so provide the full path when using translations in the application.html.erb file:

    <!-- 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 proceed to the feedback page and take care of the form. The first things to translate are the labels for the text inputs. It appears that Rails allows us to provide translations for the model attributes, and they will be automatically utilized as needed. All you need to do is namespace these translations properly:

    en:
      activerecord:
        attributes:
          feedback:
            author: "Your name"
            message: "Message"
    
    fr:
      activerecord:
        attributes:
          feedback:
            author: "Votre nom"
            message: "Message"

    The labels will now be translated automatically. As for the “submit” button, you can provide a translation for the model itself by saying:

    en:
      activerecord:
        models:
          feedback: "Feedback"
    

    In this case, Rails will automatically use the model name to construct the text on the button, which will read “Create Feedback”. But honestly, I don’t like how this text looks, so let’s stick with a generic “Submit” word and add this translation inside the global namespace:

    en:
      global:
        forms:
          submit: Submit
    
    fr:
      global:
        forms:
          submit: Soumettre

    Now utilize this translation in the feedbacks/_form.html.erb partial:

    <!-- views/feedbacks/_form.html.erb -->
    <!-- ... --->
    <%= form.submit t('global.forms.submit') %>
    

    Error messages

    We probably do not want the visitors to post empty feedback messages, therefore provide some simple validation rules for the Feedback model:

    # models/feedback.rb
    # ...
    
    validates :author, presence: true
    validates :message, presence: true, length: { minimum: 5 }
    

    But what about the corresponding error messages? How do we translate them? It appears that we don’t need to do anything at all as the rails-i18n gem already knows how to localize common errors. For example, this file contains error messages for the French locale. If you do actually want to tweak the default error messages, then check the official doc, which explains how to achieve that.

    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

    As long as there can be potentially one or more error messages, the word “error” in the subtitle should be pluralized accordingly. In English, words are usually pluralized by adding an “s” postfix, but for other languages the rules might be a bit more complex.

    I’ve already mentioned that the rails-i18n gem contains pluralization rules for all the supported languages, so we don’t need to bother writing them from scratch. All you need to do is provide the proper key for each possible case. So, for English and French there are only two possible cases: one error or many errors (of course, there can be no errors, but in this case the message won’t be displayed at all). That’s why we’re going to use to keys, namely one and other:

    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:"
    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 :"

    The %{count} here is interpolation — we take the passed value and place it right into the string.

    If a language you’re working with has more complex pluralization rules, you might need to provide more than two plural forms. Here’s an example for Russian, which has four plural forms:

    ru:
      global:
        forms:
          messages:
            errors:
              one: "Найдена одна ошибка:"
              few: "Найдены %{count} ошибки:"
              many: "Найдено %{count} ошибок:"
              other: "Найдена %{count} ошибка:"
    

    Having this in place, just utilize these translations in the feedbacks/_form.html.erb partial:

    <%= 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 %>

    Note that in this case, we pass the translation key as well as the value for the count variable. Rails will take the proper translation variant based on this number. Additionally, this count will be interpolated into each %{count} placeholder.

    Working with date and time

    Our next stop is the feedbacks/_feedback.html.erb partial. Here we need to localize two strings: “Posted by…” and date and time (created_at field). For “Posted by…”, let’s just utilize the interpolation again:

    en:
      feedbacks:
        posted_by: "Posted by %{author}"
    fr:  
      feedbacks:
        posted_by: "Envoyé par %{author}"
    

    Use translations within the partial:

    <!-- 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 %>

    But what about created_at? To handle it, we can take advantage of the localize method aliased as just l. It is very similar to Ruby’s strftime, but produces a translated version of the date (specifically, the month names are translated properly). Let’s use a predefined format called :long:

    <%= tag.article id: dom_id(feedback) do %>
      <em>
        <%= tag.time l(feedback.created_at, format: :long), datetime: feedback.created_at %><br>
        <%= t 'global.feedback.posted_by', author: feedback.author %>
      </em>
      <p>
        <%= feedback.message %>
      </p>
      <hr>
    <% end %>

    If you would like to add your very own format, this is possible too — for example:

    en:
      time:
        formats:
          custom_short: "%H:%M"

    Switching between locales

    So, our app is now fully translated… but there is a minor problem: we can’t change the locale. Come to think of it, this is more of a major issue, so let’s fix it now!

    There are a handful of possible ways to set and persist the chosen locale across the requests. We are going to stick with the following approach:

    • Our URLs will have an optional :locale parameter, and so they’ll look like this: http://localhost:3000/en/some_page.
    • If this parameter is set and the specified locale is supported, we translate the app into the corresponding language.
    • If this parameter is not set or the locale is not supported, we use a default (English) locale instead.

    Sound straightforward? Then let’s dive into the code!

    Routes

    First of all, tweak the config/routes.rb file by including a scope:

    # config/routes.rb
    
    scope "(:locale)", locale: /#{I18n.available_locales.join("|")}/ do
      # your routes here...
    end
    

    Here, we are validating the specified parameter using a RegExp to make sure that the locale is supported (note that anchor characters like \A are not permitted here).

    Concern

    Next, include an Internationalization concern inside the 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
      end
    end

    So, we have an around_action to handle locale switching. In this action, we try to fetch the locale from the URL or from headers, or use the default locale. I18n.with_locale will actually serve the content to the user in the chosen locale. But why do we want to fetch the requested locale from headers? Well, because browsers usually send the preferred locale when performing requests, so this information might be valuable for us.

    The locale_from_url method is pretty straightforward:

    def locale_from_url
      locale = params[:locale]
    
      return locale if I18n.available_locales.map(&:to_s).include?(locale)
    end

    We read the :locale named parameter and return it if the requested language is supported.

    Reading request headers

    Next, code the locale_from_headers method:

    def locale_from_headers
      header = request.env['HTTP_ACCEPT_LANGUAGE']
    
      return if header.nil?
    
      locales = parse_header header
    
      return if locales.empty?
    
      return locales.last unless I18n.enforce_available_locales
    
      detect_from_available locales
    end

    This method is slightly more complex: We try to find the header, parse it, and make sure that this language is actually supported in our application.

    Now add methods to parse the header and perform detection:

    def parse_header(header)
      header.gsub(/\s+/, '').split(',').map do |language_tag|
        locale, quality = language_tag.split(/;q=/i)
        quality = quality ? quality.to_f : 1.0
        [locale, quality]
      end.reject do |(locale, quality)|
        locale == '*' || quality.zero?
      end.sort_by do |(_, quality)|
        quality
      end.map(&:first)
    end
    
    def detect_from_available(locales)
      locales.reverse.find { |l| I18n.available_locales.any? { |al| match?(al, l) } }
      I18n.available_locales.find { |al| match?(al, locale) } if locale
    end
    
    def match?(str1, str2)
      str1.to_s.casecmp(str2.to_s).zero?
    end

    These methods are quite complex because the header might contain multiple preferred locales and we need to pick only a single one.

    Adding URL options

    Finally, we have to include the currently set locale in all the URLs generated with Rails helpers. To achieve this, override the default_url_options method:

    def default_url_options
      { locale: I18n.locale }
    end

    That’s it, our concern is now ready!

    Locale switching links

    The last step is to present two links for switching between locales. Add the following lines to the 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>

    We use url_for here so that the user is redirected to the same page once they change locale (however, please note that regular GET query params will still be lost).

    And here are the translations for the locale names:

    English:

    en:
      locales:
        en: "English"
        fr: "French"

    And French:

    fr:
      locales:
        en: "Anglais"
        fr: "Français"

    Brilliant job!

    Simplify your life with Lokalise

    By now you are likely thinking that supporting multiple languages on a big website is probably a pain. And, honestly, you are right. Of course, the translations can be namespaced, and even split into multiple YAML files if needed, but you must still make sure that all the keys are translated for each and every locale. Luckily, there is a solution to this problem: the Lokalise platform, which makes working with localization files much simpler.

    Let me guide you through the initial setup, which is nothing too complex.

    • To get started, grab your free trial.
    • Install Lokalise CLIv2, which will be used to upload and download translation files.
    • Open your personal profile page, navigate to the “API tokens” section, and generate a read/write token.
    • Create a new “software localization” project on Lokalise, give it any name, and set English as a base language. Choose French (or any other language you’re going to translate into) as the target language.
    • On the project page, click the “More” button and choose “Settings”. On this page, you should see the project ID.
    • Now, from the command line, simply run lokalise2 file upload --token <token> --project-id <project_id> --lang_iso en --file PATH/TO/PROJECT/config/locales/en.yml while providing your generated token and project ID (on Windows, you may also need to provide the full path to the file). This should upload the English version to Lokalise. Run the same command for the French locale.
    • Navigate back to the project overview page. You should see all your translation keys and values there. Of course, it is possible to edit or delete them, as well as to add new ones. Here you can also filter the keys and, for example, find any untranslated ones, which is really convenient. On top of that, you can connect your Lokalise project to third-party services including GitHub, Asana, Amazon S3, and many more.
    • After you are done editing the translations, download them back by running lokalise2 file download --token <token> --project-id <project_id> --format ruby_yaml --dest PATH/TO/PROJECT/config/locales. Great!

    Lokalise has many more features, including support for dozens of platforms and formats, the ability to order translations from professionals, and even the possibility to upload screenshots in order to read text from them. So, stick with Lokalise and make your life easier!

    Using the lokalise_rails gem

    Actually, if using a command-line interface sounds like a daunting task, I’ve got you covered. Instead you could integrate your Rails app with Lokalise by using the lokalise_rails gem. To get started, add it into your Gemfile:

    gem 'lokalise_rails'

    Then run:

    bundle install
    rails g lokalise_rails:install

    Open the config/lokalise_rails.rb file and provide mandatory options:

    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.

    Now to export translation files from your Rails app to Lokalise run the following command:

    rails lokalise_rails:export

    To import translations from Lokalise to your app, run:

    rails lokalise_rails:import

    You can also take advantage of the LokaliseManager gem, which allows you to run import/export tasks programmatically from any Ruby script. For instance, if you have a non-standard setup with translation files split into multiple directories, it’s possible to create a custom Rake task, as below:

    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

    In the example above, we are uploading all translation files to Lokalise from two different directories: config/locales and config/custom_locales.

    Conclusion

    In this article, we have thoroughly discussed how to introduce Rails I18n support and implemented it ourselves. You have learned how and where to store translations, how to look them up, what localized views are, how to translate error messages and ActiveRecord-related stuff, as well as how to switch between locales and persist the chosen locale between requests. Not bad for today, eh?

    Of course, it is impossible to cover all the ins and outs of Rails I18n in a single article, and so I recommend checking out the official guide, which gives some more detailed information on the topic and provides useful examples.

    Further reading:

    Talk to one of our localization specialists

    Book a call with one of our localization specialists and get a tailored consultation that can guide you on your localization path.

    Get a demo

    Related posts

    Learn something new every two weeks

    Get the latest in localization delivered straight to your inbox.

    Related articles
    Localization made easy. Why wait?