Software localization
Setting and Managing Locales in Rails I18n
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 Ruby 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: