Localized routes in Rails: tips and tricks

  scope "/:locale", locale: /#{I18n.available_locales.join("|")}/ do
    resources :posts
    root to: redirect("/%{locale}/posts", status: 302)
  end
  root to: redirect("/#{I18n.default_locale}", status: 302), as: :redirected_root
  get "/*path", to: redirect("/#{I18n.default_locale}/%{path}", status: 302), constraints: {path: /(?!(#{I18n.available_locales.join("|")})/).*/}, format: false

Let's take a look at the above code sample from config/routes.rb and will study what it does line by line.

  1. Locale parameter will be added to posts resource url: http://example.com/en/posts

    The following part of code is responsible for that:

      scope "/:locale", locale: /#{I18n.available_locales.join("|")}/ do
        resources :posts
      end
    

    Note, that available locales are taken from application config.

  2. Root path with locale will redirect to localized version of posts (which is a default page) http://example.com/ruhttp://example.com/ru/posts

    This is how we achieve it:

      scope "/:locale", locale: /#{I18n.available_locales.join("|")}/ do
        root to: redirect("/%{locale}/posts", status: 302)
      end
    

    We use %{locale} here, which allows to use dynamic segment from the scope inside of redirect. Also it is very interesting how you can pass the status code for redirects. There are almost no mention about it in manuals. You simply need to specify additional parameter status: 302. The same result could be achieved in few different ways:

      scope "/:locale", locale: /#{I18n.available_locales.join("|")}/ do
        root to: redirect(status: 302) {|params, request| "/#{params[:locale]}/posts"}
      end
    

    or we can skip unused variables:

      scope "/:locale", locale: /#{I18n.available_locales.join("|")}/ do
        root to: redirect(status: 302) {|params, _| "/#{params[:locale]}/posts"}
      end
    

    or we can even use dynamic segment inside a block:

      scope "/:locale", locale: /#{I18n.available_locales.join("|")}/ do
        root to: redirect(status: 302) {|_, _| "/%{locale}/posts"}
      end
    

    I should note, that redirects without block as a parameter look prettier in the output of bin/rake routes. Just compare:

    # root to: redirect(status: 302) {|_, _| "/%{locale}/posts"}
               root GET    /:locale(.:format)                redirect(302) {:locale=>/ru|en/}
    
    # root to: redirect("/%{locale}/posts", status: 302)
               root GET    /:locale(.:format)                redirect(302, /%{locale}/posts) {:locale=>/ru|en/}
    
  3. Root path will redirect to default locale http://example.com/http://example.com/en/
    The following line is responsible for that:

      root to: redirect("/#{I18n.default_locale}", status: 302), as: :redirected_root
    

    Note, that parameter as: :redirected_root is required, because we already have one root_path and we can't have the second one in Rails 4 (in comparison to Rails 3).

  4. All paths with no locale specified will be redirected to the same paths with default locale: http://example.com/postshttp://example.com/en/posts.
    This is achieved thanks to this longest line:

      get "/*path", to: redirect("/#{I18n.default_locale}/%{path}", status: 302), constraints: {path: /(?!(#{I18n.available_locales.join("|")})/).*/}, format: false
    

    Parameter /*path is so-called globbing. This parameter includes all the characters in the remaining part of url, including slashes, GET parameters and format: false allows us to capture even .json or .html signatures.

    Parameter constraint checks if path does not begin with locale and only in that case redirects to localized version. This way we will avoid infinite redirects for non-existing urls. I should note, that only GET requests are taken into account, because there shouldn't be any POST requests unless someone did some hacking or we have an error in our source code.


Let's take a look at another sample of routes

  scope "/:locale", locale: /#{I18n.available_locales.join("|")}/ do
    resources :posts

    root to: "main#index"
  end

  root to: redirect("/#{I18n.default_locale}", status: 302), as: :redirected_root

  get "/*path", to: redirect("/#{I18n.default_locale}/%{path}", status: 302), constraints: {path: /(?!(#{I18n.available_locales.join("|")})/).*/}, format: false

We don't have a redirect to the posts page and we have a separate main page here.


Language switching helper

That may seem strange, but the way how links are formed was changed in Rails 4 in comparison to Rails 3. Here is an example of language switching helper for Rails 3:

# app/helpers/application_helper.rb
module ApplicationHelper
  def lang_switcher
    content_tag(:ul, class: 'lang-switcher clearfix') do
      I18n.available_locales.each do |loc|
        concat content_tag(:li, (link_to loc, params.merge(locale: loc)), class: (I18n.locale == loc ? "active" : ""))
      end
    end
  end
end

Using the above helper in Rails 3 will give us the usual /en and /ru links for the main page. But in Rails 4 the same code will give us /en/main/index and /ru/main/index instead. And this is how the very same helper should be implemented in Rails 4:

# app/helpers/application_helper.rb
module ApplicationHelper
  def lang_switcher
    content_tag(:ul, class: 'lang-switcher clearfix') do
      I18n.available_locales.each do |loc|
        locale_param = request.path == root_path ? root_path(locale: loc) : params.merge(locale: loc)
        concat content_tag(:li, (link_to loc, locale_param), class: (I18n.locale == loc ? "active" : ""))
      end
    end
  end
end

It took some time for me to understand how things work here, so I hope you've found something useful in this blog post too.