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

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 English
    • index.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:

    1. Ensuring all keys have unique names becomes increasingly difficult.
    2. 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:

    1. one – Singular (e.g., “1 error”).
    2. 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 %>
    1. The t method fetches the correct translation based on the key and the count variable.
    2. Rails selects the appropriate plural form (one, other, few, or many) based on the count.
    3. 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:

    1. URLs will include an optional :locale parameter, like: http://localhost:3000/en/some_page.
    2. If this parameter is set and matches a supported locale, Rails will switch to that language.
    3. 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

    1. 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.
    2. 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

    1. Restart your Rails server.
    2. Visit URLs like:
      • http://localhost:3000/en → App will render in English.
      • http://localhost:3000/fr → App will render in French.
    3. 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

    1. Get started: Sign up for a free trial on Lokalise.
    2. Generate your API token:
      • Go to your personal profileAPI tokens → generate a read/write token.
    3. Create a new project:
    4. Find your project ID: On the project page, click “More” → “Settings.” The project ID will be listed here.
    5. 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!

    Further reading on Rails i18n

    Related articles
    Stop wasting time with manual localization tasks. 

    Launch global products days from now.