Setting and Managing Locales in Rails i18n
This article explores several solutions for setting and managing locales in Rails i18n.
Join the DZone community and get the full member experience.
Join For FreeOne of the previous articles was covering I18n in Rails. We talked about storing and fetching translations, localizing the app and other useful stuff. What we have not discussed, however, are the different ways to manage locale 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 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 at GitHub.
By the way, if you are only starting to learn Rails, here is the great list of helpful resources.
Preparing the 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
In order 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 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 Domain Name
The first solution we will discuss is setting locale based on the first level domain's name so for example example.com
will render English version of the site, whereas example.pl
- 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 may 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 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 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.
PhraseApp 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 PhraseApp is here to make the process of managing translations much easier!
You may try PhraseApp 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 translations 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 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. I thank you for staying with me and happy coding!
Published at DZone with permission of Ilya Bodrov. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments