Go to main contentGo to footer
SEO
|
10 February 15

Multilingual Applications: Managing Meta Tags and Sitemaps for Better Indexing

Finding your way through the convoluted world of indexing in a multilingual application means following a set of SEO rules to correctly generate alternate links and sitemaps.

Handling the SEO side of a web application is as important as it is easy to implement if we're using Rails: guides on the topic are available just about everywhere.

Generating sitemaps, too, with gems like sitemap_generator, comes at practically zero cost.

The trouble starts when we have to deal with multilingual applications, where some sections may only be available in a subset of the supported languages.

Generating URLs

To make it easier to internationalize the URLs in our applications, we can rely on gems like route_translator. This one, for example, extends the Rails routing DSL with a special localized directive:

# config/routes.rb
localized do
  get "/about" to: 'static#about', as: :about
  resources :posts, only: [:show]
end

Route translations are specified in our locale files:

# config/locales/fr.yml
fr:
  routes:
    posts: nouvelles
    about: a-propos-de-nous

# config/locales/en.yml
en:
  routes:
    posts: posts
    about: about

# config/locales/it.yml
it:
  routes:
    posts: articoli
    about: chi-siamo

For every route declared inside the block, a dedicated route helper is generated for each language, along with a helper that dynamically picks the right route for the active language:

about_it_path    # => /chi-siamo
about_en_path    # => /en/about
about_fr_path    # => /fr/a-propos-de-nous
about_path       # => dipendente dalla lingua corrente

With these helpers, building a language switcher becomes very simple:

/ app/views/static/about.html.slim
- content_for(:switch_locale) do
  ul.switch_locale
    - I18n.available_locales.each do |locale|
      li= I18n.with_locale(locale) do
        = link_to "Switch to #{locale}", about_url

Now imagine a site with twenty or so routes to translate: should we repeat a snippet like this in every view? That wouldn't be very DRY. Why not use the url_for method to generalize it?

/ app/views/layout/application.html.slim
ul.switch_locale
  - I18n.available_locales.each do |locale|
    li= I18n.with_locale(locale) do
      = link_to "Switch to #{locale}", url_for(locale: locale)

This little trick works until we have to deal with models whose slug is itself translated (for example with the globalize + friendly_id combo):

/ /app/views/posts/show.html.slim

= I18n.with_locale(:it) { post.slug } # => "il-mio-primo-post"
= I18n.with_locale(:en) { post.slug } # => "my-first-post"
= I18n.with_locale(:fr) { post.slug } # => "mon-premier-nouvell"

= params.inspect
/ => {"controller" => "posts", "action" => "show", "id" => "my-first-post"}

- %i(it en fr).each do |locale|
  = I18n.with_locale(locale) { url_for(locale: locale) }

/ => /articoli/my-first-post
/ => /en/posts/my-first-post
/ => /fr/nouvelles/my-first-post

What happened? Well, the url_for method builds the URL by merging the keys it receives (in our case, locale) with the keys of the global params hash — in our case controller, action and id.

Unfortunately for us, the id key holds the slug of the current page ("my-first-post"). The post_path route generated by route_translator can translate the controller and action parts of the URL, but there's little it can do about the id parameter it receives as input, which can't be traced back to the Post model it came from.

We can work around this by making the locale switcher slightly more configurable:

/ app/views/layout/application.html.slim
ul.switch_locale
  - I18n.available_locales.each do |locale|
    li= I18n.with_locale(locale) do
      - url = yield(:current_page_url) || url_for(locale: locale)
      = link_to "Switch to #{locale}", url

/ app/views/posts/show.html.slim
- content_for(:current_page_url) { post_url(@post) }

This way, only for routes with translated slugs, we're free to specify a particular method to use for generating the URL:

<li><a href="/articoli/il-mio-primo-post">Switch to it</a></li>
<li><a href="/en/posts/my-first-post">Switch to en</a></li>
<li><a href="/fr/nouvelles/mon-premier-nouvell">Switch to fr</a></li>

Once translated URL generation is in place, we can easily generate the alternate links inside our <head>, following the same logic:

/ app/views/layouts/application.html.slim

- I18n.available_locales.each do |locale|
  - I18n.with_locale(locale) do
    - url = yield(:current_page_url) || url_for(locale: locale)

    - if locale == I18n.default_locale
      link rel="alternate" hreflang="x-default" href=url

    link rel="alternate" hreflang=locale href=url

Sitemap

At this point we have all the information we need to generate a complete sitemap.

Let's imagine a site with 2 different domains: myblog.it and myblog.com. The first domain is for Italian only, while the second serves content translated into English and French.

Searching various blogs for ways to generate a sitemap, I didn't find any satisfying results: most articles just copy what's already in Google's guidelines or in the SeoMoz cheatsheet.

After many discussions with our SEO expert, we decided to generate two separate sitemaps, one per domain, also listing the alternate URLs inside the <url> XML tag. You can reproduce this behavior with these lines of code:

# app/controller/sitemap_controller.rb
class SitemapController < ApplicationController
  def index
    @available_locales = @domain == "myblog.it" ? [:it] : [:en, :fr]
  end
end

# app/views/sitemap/index.xml.builder
xml.urlset( "xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9", "xmlns:xhtml" => "http://www.w3.org/1999/xhtml") do
  @available_domain_locales.each do |locale|
    I18n.with_locale(locale) do
      xml.url
      xml.loc posts_url
      xml.priority "1.0"
      xml.changefreq "monthly"
      xml.lastmod "2015-01-01"
      I18n.available_locales.each do |other_locale|
        I18n.with_locale(other_locale) do
          xml.tag! "xhtml:link", rel: 'alternate', hreflang: other_locale.to_s, href: posts_url
        end
      end
    end
  end
end

Here's the result:

# http://myblog.it/sitemap.xml

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>http://myblog.it/articoli/il-mio-primo-post</loc>
    <priority>1.0</priority>
    <lastmod>2015-01-01</lastmod>
    <changefreq>monthly</changefreq>
    <xhtml:link rel="alternate" hreflang="it" href="http://myblog.it/articoli/il-mio-primo-post" />
    <xhtml:link rel="alternate" hreflang="en" href="http://myblog.com/posts/my-first-post" />
    <xhtml:link rel="alternate" hreflang="fr" href="http://myblog.com/fr/nouvelles/mon-premier-nouvell" />
  </url>
  <url>
    <loc>http://myblog.it/chi-siamo</loc>
    <priority>1.0</priority>
    <lastmod>2015-01-01</lastmod>
    <changefreq>monthly</changefreq>
    <xhtml:link rel="alternate" hreflang="it" href="http://myblog.it/chi-siamo" />
    <xhtml:link rel="alternate" hreflang="en" href="http://myblog.com/about-us" />
    <xhtml:link rel="alternate" hreflang="fr" href="http://myblog.com/fr/a-propos-de-nous" />
  </url>
</urlset>

# http://myblog.com/sitemap.xml

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>http://myblog.it/posts/my-first-post</loc>
    <priority>1.0</priority>
    <lastmod>2015-01-01</lastmod>
    <changefreq>monthly</changefreq>
    <xhtml:link rel="alternate" hreflang="it" href="http://myblog.it/articoli/il-mio-primo-post" />
    <xhtml:link rel="alternate" hreflang="en" href="http://myblog.com/posts/my-first-post" />
    <xhtml:link rel="alternate" hreflang="fr" href="http://myblog.com/fr/nouvelles/mon-premier-nouvell" />
  </url>
  <url>
    <loc>http://myblog.it/fr/nouvelles/mon-premier-nouvell</loc>
    <priority>1.0</priority>
    <lastmod>2015-01-01</lastmod>
    <changefreq>monthly</changefreq>
    <xhtml:link rel="alternate" hreflang="it" href="http://myblog.it/articoli/il-mio-primo-post" />
    <xhtml:link rel="alternate" hreflang="en" href="http://myblog.com/posts/my-first-post" />
    <xhtml:link rel="alternate" hreflang="fr" href="http://myblog.com/fr/nouvelles/mon-premier-nouvell" />
  </url>
  <url>
    <loc>http://myblog.com/about-us</loc>
    <priority>1.0</priority>
    <lastmod>2015-01-01</lastmod>
    <changefreq>monthly</changefreq>
    <xhtml:link rel="alternate" hreflang="it" href="http://myblog.it/chi-siamo" />
    <xhtml:link rel="alternate" hreflang="en" href="http://myblog.com/about-us" />
    <xhtml:link rel="alternate" hreflang="fr" href="http://myblog.com/fr/a-propos-de-nous" />
  </url>
  <url>
    <loc>http://myblog.com/fr/a-propos-de-nous</loc>
    <priority>1.0</priority>
    <lastmod>2015-01-01</lastmod>
    <changefreq>monthly</changefreq>
    <xhtml:link rel="alternate" hreflang="it" href="http://myblog.it/chi-siamo" />
    <xhtml:link rel="alternate" hreflang="en" href="http://myblog.com/about-us" />
    <xhtml:link rel="alternate" hreflang="fr" href="http://myblog.com/fr/a-propos-de-nous" />
  </url>
</urlset>

With this setup, each domain provides the full list of URLs it handles and, for each URL, the full list of its alternates, including cross-domain ones, so search engines can get the complete URL mapping right away.

footer