When you start a website you usually start small: one website, hosted on one domain, targeting one country and one language. Simple, right?

Then, one day you decide to go international (exciting!). One problem, though: people don’t all speak/read the same language everywhere. You must localize you content in multiple languages.

The standardized way to do so is to use a locale for each language and geographical region you are targeting. For instance if you want to make your website available in the US, France and Canada you’ll need 4 locales:

en : English (US)

fr : French (France)

fr-CA : French (Canada)

en-CA : English (Canada)

side note: this is a ‘pragmatic’ approach to locale naming, where we drop the regional part when it is not needed (more information here).

Great. Now, how do you make your website available in all these locales?

Locales and URLs

The first rule of Search Engine Optimization when you go international is: the locale must appear in the URL. Google suggests choosing among several options:

URL parameters: example.com?locale=fr

Subdirectories with a generic top-level domain (gTLD): example.com/fr/

Subdomains with a generic top-level domain (gTLD): fr.example.com

Country-specific top-level domains (ccTLD): example.fr

There is no one-size-fits-all approach. The best solution really depends on your specific requirements (this article from Moz may give you some pointers). At Drivy we decided to use ccTLDs because it was the best solution SEO-wise to target different countries. Plus it’s prettier.

First implementation

In Rails this is pretty straightforward. You just need to point your new domain DNS to your application server and drop this code in your ApplicationController (inspired by the official Rails guide):

class ApplicationController < ActionController::Base

before_action :set_locale



def set_locale

I18n.locale = request.host.split('.').last || :en

end

end

We basically take the last part of the URL domain (fr in the case of example.fr) and we set the locale to this value. Simple.

It works, but it has major drawbacks:

it doesn’t work for ccTLDs that don’t correspond to a valid locale (e.g. example.ca)

it doesn’t work with second-level domains (e.g. example.co.uk)

it doesn’t work in dev environment (where you usually use http://localhost:3000)

One other thing that may cause trouble down the road: what if you can’t map a ccTLD to a given locale? This can happen in several scenarios:

the ccTLD is already taken by somebody else — in which case the next best solution is to use a subdomain

you want to offer several locales for a given ccTLD (e.g. French and English for Canada)

And this gets even more complex as you add different environments (production, staging, dev) and more subdomains.

There must be a more robust way.

Second implementation

Let’s be more explicit and define a clear one-to-one mapping between the locale and the host:

HOSTS_MAPPING = {

'en' => 'example.com',

'fr' => 'example.fr',

'fr-CA' => 'fr.example.ca',

'en-CA' => 'en.example.ca'

}

Now let’s use this new mapping in our ApplicationController:

class ApplicationController < ActionController::Base

before_action :set_locale def set_locale

I18n.locale = HOSTS_MAPPING.invert[request.host] || I18n.default_locale

end

end

We can now easily make this work in our staging environment by using a different hash:

HOSTS_MAPPING_STAGING = {

'en' => 'staging.example.com',

'fr' => 'staging.example.fr',

'fr-CA' => 'fr.staging.example.ca',

'en-CA' => 'en.staging.example.ca'

}

side note: this breaks dev/prod parity, so you should probably avoid this and instead buy a dedicated domain for your staging environment

Same thing for our dev environment:

HOSTS_MAPPING_DEV = {

'en' => 'dev-example.com',

'fr' => 'dev-example.fr',

'fr-CA' => 'fr.dev-example.ca',

'en-CA' => 'en.dev-example.ca'

}

side note: this means every member of your dev team must setup his etc/hosts to make all the domains above point to his/her local machine

One major gotcha: emails

Our implementation works pretty well and is pretty flexible. But there is one big gotcha: what host do we use when we send emails to our users?

The first solution that comes to mind is to use the default host and then redirect the user based on his latest locale:

One big problem: although it is easy to share cookies across subdomains, it’s impossible to share cookies across top-level domains. And since server-side sessions are usually based on cookies this means you cannot have proper sessions across all your domains — unless you implement some kind of OAuth mechanism, which isn’t trivial.

If one our your users signs in on your French website (example.fr) he won’t be signed in on example.com, which means you won’t be able to retrieve his locale and you won’t be able to automatically redirect him to the correct domain. Bummer.

One solution is to use the correct domain in your emails. But since we are sending emails asynchronously we can’t rely on the request object like we did previously inside our ApplicationController.

We can work around this by hacking ActionMailer a bit:

class MyMailer < ActionMailer::Base

def welcome(user)

@user = user

mail(to: user.email)

end def url_options

super.merge({host: HOSTS_MAPPING[@user.locale]})

end

end

We retrieve the user’s locale (stored in our database) and we set the default host dynamically. All the links present in the email will use the correct host. It works!

More gotchas?

A few other gotchas to be aware of: