Software localization

Setting and Managing Locales in Rails I18n

Let's discuss different ways of managing locales across requests in Rails i18n so your app is fully ready for localization.
Software localization blog category featured image | Phrase

One of the previous articles dealt with I18n in Rails. We talked about storing and fetching translations, localizing the app and other useful stuff. In addition to Rails i18n, we have not yet discussed the different ways to manage locales across requests. By default, Rails is going to use locale set in the I18n.default_locale (which is :en or any other value you define in the configuration) or the value from I18n.locale if it was explicitly defined. Of course, if an application supports multiple languages, its users need a way to change their locale and their choice should be persisted. Therefore, in this article we will explore the following solutions:

  • Provide locale's name as a GET parameter (example.com?locale=en)
  • Provide it as a part of a domain name (en.example.com)
  • Set locale based on the user agent sent by the browser
  • Set locale based on the user's location

The source code for the demo app is available on GitHub.

🗒 Note » By the way, if you are only starting to learn Rails, here is a great list of helpful Reuby resources.

Preparing the Rails App

In this demo, I am going to use Rails 5.0.0.1 but the described concepts apply to older versions as well. To start off, create a new application without the default testing suite:

$ rails new Localizer -T

To provide support for additional languages, include the rails-i18n gem into your Gemfile: Gemfile

    [...]

    gem 'rails-i18n'

    [...]

Install it

    $ bundle install

I am going to provide support for English and Polish languages in this demo, but you may pick anything else. Let's explicitly define the supported locales: config/application.rb

    config.i18n.available_locales = [:en, :pl]

Also quickly set up two small pages managed by the PagesController: pages_controller.rb

    class PagesController < ApplicationController

    end

views/pages/index.html.erb

    <h1><%= t('.title') %></h1>

    <%= link_to t('pages.about.title'), about_path %>

*views/pages/about.html.erb

    <h1><%= t('.title') %></h1>

    <%= link_to t('pages.index.title'), root_path %>

Don't forget that the t method is an alias for I18n.translate and it looks up translation based on the provided key. Here are our translations: config/locales/en.yml

    en:

      pages:

        index:

          title: 'Welcome!'

        about:

          title: 'About us'

config/locales/pl.yml

    pl:

      pages:

        index:

          title: 'Powitanie!'

        about:

          title: 'O nas'

As long as we are naming these keys based on the controller's and the view's names, inside the .html.erb file we can simply say t('.title') omitting the pages.index or pages.about parts. Set the routes: config/routes.rb

  get '/about', to: 'pages#about', as: :about

  root 'pages#index'

Lastly, provide the links to change the site's locale (URLs will be empty for now): shared/_change_locale.html.erb

    <ul>

      <li><%= link_to 'English', '#' %></li>.

      <li><%= link_to 'Polska', '#' %></li>

    </ul>

Render this partial inside the layout: layouts/application.html.erb

    <%= render 'shared/change_locale' %>

Nice! Preparations are done and we can proceed to the main part.

Setting Locale Based on Your Toplevel Domain

The first solution is setting the locale based on the first level domain's name. For example example.com will render an English version of the site, whereas example.pl the Polish version. This solution has a number of advantages and probably the most important one is that users can easily understand what language they are going to use. Still, if your website supports many locales, purchasing multiple domains can be costly. In order to test this solution locally, you'll need to configure your workstation a bit by editing the hosts file. This file is found inside the etc directory (for Windows, that'll be %WINDIR%\system32\drivers\etc). Edit it by adding

    127.0.0.1   localizer.com

    127.0.0.1   localizer.pl

Now visiting localizer.com:3000 and localizer.pl:3000 should navigate you to our Rails app. A very common place to set locale is the before_action inside the ApplicationController: application_controller.rb

    [...]

    before_action :set_locale

    private

    def set_locale

      I18n.locale = extract_locale || I18n.default_locale

    end

    [...]

To grab the requested host name, use request.host: application_controller.rb

    [...]

    def extract_locale

    parsed_locale = request.host.split('.').last

    I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale : nil

    end

    [...]

In this method, we strip the last part of the domain's name (com, pl etc) and check whether the requested locale is supported. If yes - return it, otherwise, say nil. Now tweak the links to change locale: shared/_change_locale.html.erb

    [...]

  <li><%= link_to 'English', "http://localizer.com:3000" %></li>

  <li><%= link_to 'Polska', "http://localizer.pl:3000" %></li>

    [...]

To make it a bit more user-friendly let's append the current path to the URL: shared/_change_locale.html.erb

    [...]

  <li><%= link_to 'English', "http://localizer.com:3000#{request.env['PATH_INFO']}" %></li>

  <li><%= link_to 'Polska', "http://localizer.pl:3000#{request.env['PATH_INFO']}" %></li>

    [...]

Now you may test the result!

Employing Subdomain

Of course, instead of purchasing multiple first-level domains, you may register subdomains in your domain zone, for example, en.localizer.com and pl.localizer.com. The extract_locale method should be changed like this: application_controller.rb

    [...]

    def extract_locale

    parsed_locale = request.subdomains.first

    I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale : nil

    end

    [...]

Of course, the links will look a bit different as well: shared/_change_locale.html.erb

  <li><%= link_to 'English', "http://en.localizer.com:3000#{request.env['PATH_INFO']}" %></li>

  <li><%= link_to 'Polska', "http://pl.localizer.com:3000#{request.env['PATH_INFO']}" %></li>

Setting Locale Based on HTTP GET Parameters

Another very common approach is employing the HTTP GET params, for example localhost:3000?locale=en. This will require us to change the extract_locale method once again: application_controller.rb

    [...]

  def extract_locale

    parsed_locale = params[:locale]

    I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale : nil

  end

    [...]

The problem, however, is the need to persist the chosen locale between the requests. Of course, you may say link_to root_url(locale: I18n.locale) every time, but that's not the best idea. Instead, you can rely on the default_url_options method that sets default params for the url_for method and other methods that rely on it: application_controller.rb

    [...]

    def default_url_options

      { locale: I18n.locale }

    end

    [...]

This will make your route helpers automatically include the ?locale part. However, I do not really like this approach, mostly because of that annoying GET param. Therefore let's discuss yet another solution.

Using Routes' Scopes

As you probably recall, routes can be scoped and this feature may be used to persist the locale's name easily: config/routes.rb

    [...]

  scope "(:locale)", locale: /en|pl/ do

    get '/about', to: 'pages#about', as: :about

    root 'pages#index'

  end

    [...]

By wrapping :locale with round brackets we make this GET param optional. locale: /en|pl/ sets the regular expression checking that this param can only contain en or pl, therefore any of these links is correct:

  • http://localhost:3000/about
  • http://localhost:3000/en/about
  • http://localhost:3000/pl/about

Modify the links to switch locale: shared/_change_locale.html.erb

  <li><%= link_to 'English', root_path(locale: :en) %></li>

  <li><%= link_to 'Polska', root_path(locale: :pl) %></li>

In my point of view, this solution is much tidier than passing locale via the ?locale GET param.

Inferring Locale Based on User's Settings

When locale was not set explicitly, you will fallback to the default value set in I18n.default_locale but we may change this behaviour. To get an implicit locale, you may either use HTTP headers or information about a visitor's location, so let's see those two approaches in action now.

Using HTTP headers

There is a special HTTP header called Accept-Language that browsers set based on the language preferences on a user's device. Its contents usually looks like en-US,en;q=0.5, but we are interested only in the first two characters, therefore the extract_locale method can be tweaked like this: application_controller.rb

    [...]

    def extract_locale

        parsed_locale = params[:locale] || request.env['HTTP_ACCEPT_LANGUAGE'].scan(/^[a-z]{2}/)[0]

      I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale : nil

    end

    [...]

There is a great gem http_accept_language that acts as Rack middleware and helps you to solve this problem more robustly.

Employing User's Location

Another approach would be to set the default locale based on the user's location. This solution is usually considered unreliable and generally not recommended, but for completeness' sake, let's discuss it as well. In order to fetch the user's location, let's employ a gem called geocoder that can be used for lots of different tasks and even provides hooks for ActiveRecord and Mongoid. In this demo, however, things will be much simpler. First of all, add this new gem Gemfile

    [...]

    gem 'geocoder'

    [...]

and run

    $ bundle install

Now we can take advantage of request.location.country_code to see the user's country. The resulting string, however, is in capital case, so we are going to downcase it. Here is the corresponding code: application_controller.rb

    [...]

    def extract_locale

      parsed_locale = if params[:locale]

                        params[:locale]

                      else

                        request.location.country_code ? request.location.country_code.downcase : nil

                      end

        I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale : nil

    end

    [...]

The only problem here is that you won't be able to test it locally, as request.location.country_code will always return "RD". Still, you may deploy your app on Heroku (this will take literally a couple of minutes) and test everything there by utilizing open proxy servers. Once again though I want to remind you that setting the user's locale based on its location is not considered a recommended practice, because someone may, for example, be visiting another country during a business trip.

Phrase and Managing Translations

Of course, introducing the mechanism to switch and persist locale is very important for any multi-language app, but that makes little sense if you have no translations. And Phrase is here to make the process of managing translations much easier! You may try Phrase for free for 14 days right now. It supports a huge list of different languages and frameworks from Rails to JavaScript and allows to easily import and export translation data. What's cool, you can quickly understand which translation keys are missing because it's easy to lose track when working with many languages in big applications. Therefore, I really encourage you to give it a try!

Conclusion

In this application, we covered different ways to switch and persist locale data among requests. We've seen how locale can be passed as a part of the domain's name and as a part of URL. Also, we've talked about inferring locale based on HTTP header and on user's location. Hopefully, this article was useful and interesting for you. Happy coding!

Want to keep exploring internationalization in Ruby on Rails? Feel free to stop by the following tutorials: