Skip to content
← All posts
5 min read Dawid Skłodowski

Internationalising a Rails store for many markets

Selling across countries means translating not just your interface but your data, your URLs, your formats, and your SEO. A deep-dive on Rails i18n at scale — locale files, translated content, locale routing, and the traps.

Internationalising an application is one of those tasks that everyone underestimates. “Translate the labels” is maybe 20% of the work; the rest is the data, the URLs, the number and date formats, the pluralisation rules, and the SEO — each with its own traps. For an e-commerce store selling across markets, getting this right is the difference between feeling local in every country and feeling like a clumsy machine translation. Here is what the full job actually involves in Rails.

The foundation: I18n and locale files

Rails ships with the I18n gem, and the basic mechanism is sound: text lives in locale files keyed by a locale, and you look it up by key:

# config/locales/en.yml
en:
  cart:
    title: "Your basket"
    item_count:
      one: "%{count} item"
      other: "%{count} items"

# config/locales/pl.yml
pl:
  cart:
    title: "Twój koszyk"
    item_count:
      one: "%{count} produkt"
      few: "%{count} produkty"
      many: "%{count} produktów"
<h1><%= t("cart.title") %></h1>
<p><%= t("cart.item_count", count: @cart.size) %></p>

Two things to internalise immediately. First, pluralisation is not just one/other. English has two plural forms; Polish has four (one/few/many/other) with non-obvious rules; Arabic has six. The count: interpolation and per-locale plural keys handle this if you supply the right forms — hard-coding “1 item / N items” logic in your views breaks the moment you add a Slavic language. Let the I18n pluralisation machinery do it. Second, keep keys semantic (cart.title), not text-derived, so translators and code stay decoupled.

Translating your data, not just your chrome

Here is the part that separates real internationalisation from a translated menu bar: in a store, the content needs translating too. Product names, descriptions, category names, CMS pages — this is data in your database, not strings in a YAML file, and it needs a per-locale value. The common Rails answer is a gem like globalize or mobility that stores translations in side tables:

class Product < ApplicationRecord
  translates :name, :description     # name/description vary by locale
end

I18n.locale = :pl
product.name        # => the Polish name
I18n.locale = :en
product.name        # => the English name

This is a genuine data-modelling decision with consequences: translations live in separate tables keyed by record and locale, which affects your queries, your search indexing (you now index per locale), and your admin UI (editors need to enter each language). Plan for it — content translation is usually the largest and most ongoing part of i18n, because every new product needs translating, forever, while the interface strings are written once.

Locale in the URL: routing and SEO

How a visitor’s locale is chosen and reflected in the URL is both a UX and an SEO decision, and getting it wrong quietly wrecks your search visibility. The robust patterns put the locale in the URL so each language has its own addressable, crawlable pages:

# path-based: /en/products, /pl/products
scope "/:locale", locale: /en|pl|de/ do
  resources :products
end

# set it from the URL on every request
before_action :set_locale
def set_locale
  I18n.locale = params[:locale] || I18n.default_locale
end

The alternatives are a locale subdomain (pl.shop.com) or a country-code top-level domain (shop.pl) — the right choice depends on how distinctly you want markets branded and how your SEO is structured. What you must not do is serve different languages at the same URL based on a cookie or Accept-Language header alone: search engines then cannot index each language separately, and a user cannot share a link in their language. The non-negotiable SEO requirement is hreflang tags telling search engines which URL serves which language, so Google shows the right one and does not treat your translations as duplicate content:

<link rel="alternate" hreflang="en" href="https://shop.com/en/product/42" />
<link rel="alternate" hreflang="pl" href="https://shop.com/pl/product/42" />

Formats: numbers, dates, and money are localised too

A market does not feel local until the formats are right. 1,234.56 in the UK is 1 234,56 in Poland and 1.234,56 in Germany; date order, currency symbol placement, and first day of the week all vary. Rails localises these through I18n too:

l(Date.today, format: :long)        # localised date
number_to_currency(price)           # respects the locale's format

This dovetails with the multi-currency work: the currency (what you charge) and the format (how you display a number) are separate concerns, both locale-aware. A German customer might pay in EUR shown as 19,99 € while a French one sees 19,99 € with different spacing — same currency, different formatting, both driven by locale.

The traps worth naming

A few failure modes that bite teams doing this for the first time:

  • Missing translations failing silently or loudly. Decide your policy: in production you usually want a sensible fallback (to a default locale) rather than a raw key shown to a user; in development and CI you want missing keys to fail, so you catch them before they ship. The i18n-tasks gem finds missing and unused keys.
  • Concatenating translated fragments. Building a sentence by joining translated words (t(:you_have) + count + t(:items)) produces garbage in languages with different word order. Translate whole phrases with interpolation, never assemble them from parts.
  • Hard-coded English in flashes, mailers, validations, and PDFs. The strings hide in the corners — error messages, emails, generated documents. A real i18n pass has to reach all of them, not just the views.
  • Text expansion in the UI. German runs ~30% longer than English; a button that fits in English overflows in German. Design and test layouts with the longest language.

Verdict

Internationalising a store is far more than translating labels — it is translating your data, structuring your URLs for both users and search engines, localising every format, respecting each language’s pluralisation, and chasing hard-coded English out of every corner. Lean on Rails’ I18n machinery for the interface and formats, use a translation gem for database content, put the locale in the URL with hreflang for SEO, and adopt tooling (i18n-tasks) to keep your keys honest. Treat content translation as the ongoing cost it is, plan for it in the data model, and your store can feel genuinely local in every market rather than like English with the words swapped. The work is substantial, but for a business selling across borders it is the difference between looking native and looking foreign.