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.
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-siamoFor 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_urlNow 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-postWhat 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>
Alternate links
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=urlSitemap
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
endHere'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.