DOCS LLMs

Translations (i18n)

Almost every user-facing string RailsFast ships (auth screens, Settings, the dashboard, emails, flash messages, the admin's navigation, the component library's built-in words) resolves through Rails' I18n instead of being hardcoded. Two locales ship in the box: English, the source of truth, and Spanish, as a worked example of what a second language looks like.

A few strings are still English in the code: some billing labels in the admin (OrganizationBillingPresenter), the default copy of some marketing partials (the CTA's default title and subtitle, the social proof's trust_label), and the offline banner's default lines. Pass your own translated values where your app shows them.

Adding a market is "write a locale file", never "hunt down hardcoded strings across 200 views".

If your app only ever needs English, you can turn all of this off in two steps. See English-only apps at the bottom. Nothing here is mandatory.

What you get

  • Locale files organized by domain: config/locales/en/demo.yml, devise_views.yml, mailers.yml, layouts.yml, components.yml, madmin.yml and so on, instead of one unreviewable en.yml. Files for gem-owned keys stay top-level (devise.en.yml, pay.en.yml).
  • A language switcher that appears by itself once you have two locales: a dropdown in the footer, and a segmented control in Settings. It works with JavaScript off, and each language is written in itself ("Español", never "Spanish").
  • Emails in the recipient's language, not the sender's: mailers resolve against the recipient's saved locale.
  • A CI gate that fails the build when a locale is missing a key, or when a key exists that nothing references.
  • Fallbacks, and loud gaps in English. config.i18n.fallbacks = true in config/application.rb applies in every environment, so a key missing from Spanish shows the English copy. Only a key missing from English itself raises, in development and test (raise_on_missing_translations), so you find it while writing the code.

How the locale is chosen

For each request, in order, the first one that names a language you support wins (app/controllers/concerns/locale_preference.rb):

  1. ?locale=es in the URL. This is what the switcher uses. The choice is saved to a one-year cookie and, when signed in, to the user's account, so it is needed once and every later URL stays clean.
  2. The signed-in user's saved preference (users.locale), the same on every device they sign in from.
  3. The locale cookie: an anonymous visitor's earlier choice, kept for a year so it survives browser restarts and sign-out.
  4. The browser's Accept-Language header, with proper quality-value handling, so en;q=0.5, es;q=0.9 reads as "prefers Spanish". It is consulted only when nobody has chosen anything.
  5. config.i18n.default_locale.

An explicit choice always beats the browser: someone who picked Español on an English laptop keeps Español.

Every tag on that path goes through SupportedLocale.match (app/models/supported_locale.rb), so any shape a browser or a link sends finds the locale you ship: any case (EN), any region (es-MX finds es), a lone language for your only regional locale (pt finds pt-BR), and Chinese scripts (zh-Hant finds zh-TW). To serve a language you do not ship with one you do, map it in config/railsfast/railsfast.yml:

locale_aliases:
  ca: es
  gl: es

There is deliberately no /:locale URL segment. Every route helper in your app keeps working untouched. If you later want per-locale URLs for SEO (/es/precios with hreflang tags), wrap your public routes in scope "(:locale)": the path parameter resolves through the same chain.

Adding a language

Say you are launching in the Netherlands.

1. List it in config/application.rb:

config.i18n.available_locales = [ :en, :es, :nl ]

2. Mirror the English files:

cp -R config/locales/en config/locales/nl
cp config/locales/devise.en.yml config/locales/devise.nl.yml
cp config/locales/pay.en.yml config/locales/pay.nl.yml
# then translate the values (and the top-level `en:` key) in each copy

3. Run the gate. It tells you exactly what is still missing:

bundle exec i18n-tasks missing      # keys with no Dutch value yet
bundle exec i18n-tasks health       # the full check bin/ci runs

That is the whole process. The language switcher picks up Dutch automatically, bin/ci fails until the mirror is complete, and framework strings (dates, number formats, validation errors, Devise's own messages) come from the rails-i18n and devise-i18n gems, so you do not translate those yourself.

Flags

The switcher shows a flag per language, from an opinionated default map in ApplicationHelper::DEFAULT_LOCALE_FLAGS: es 🇪🇸, en 🇬🇧, pt 🇵🇹, and so on. A language is not a country, so this is an editorial call, and yours to overrule. If your market is Mexico, the US or Brazil, say so in config/railsfast/railsfast.yml:

locale_flags:
  en: "US"
  es: "MX"
  pt: "BR"

Locales that already name their region (es-MX, pt-BR) resolve to that region's flag with no configuration at all.

Writing translatable code

Views use lazy lookup, where the key is scoped to the template's own path:

<%# app/views/demo/settings/show.html.erb %>
<h1><%= t(".title") %></h1>          <%# demo.settings.show.title %>
# config/locales/en/demo.yml
en:
  demo:
    settings:
      show:
        title: "Settings"

A few conventions worth keeping, because the CI gate assumes them:

  • Interpolate, never concatenate. t(".greeting", name: user.name) with greeting: "Hi %{name}". Word order differs by language, so gluing strings together is a bug in every language but English.
  • Pluralize with count:, giving one: and other: keys. Some languages have more forms than English, and rails-i18n knows which.
  • Suffix _html on any key whose value contains markup (terms_html), so Rails does not escape it.
  • Dates and times go through l(date, format: :long), not strftime, which is English-only.

In mailers, resolve against the recipient (ApplicationMailer provides both helpers):

locale = recipient_locale(user)
subject = t_in_locale(locale, "mailers.welcome.subject", app: app_name)

In JavaScript, pass strings from the server as Stimulus values rather than hardcoding them in the controller. The password visibility toggle and the offline banner show the pattern.

The CI gate

bin/ci runs bundle exec i18n-tasks health, which fails on:

  • a key used in code but missing from a locale file,
  • a key sitting in a locale file that nothing references anymore,
  • inconsistent interpolation variables between locales,
  • files that are not normalized (ordering and formatting).

Useful commands while working:

bundle exec i18n-tasks missing        # what's not translated yet
bundle exec i18n-tasks unused         # dead keys to delete
bundle exec i18n-tasks normalize      # sort + format every locale file
bundle exec i18n-tasks health         # everything above at once

config/i18n-tasks.yml derives its locale list from the locale folders you actually have, so the gate follows your app rather than the template's defaults. It also teaches i18n-tasks to read organization_t(...) calls like t(...) calls (the ast_matchers lines); keep them if you customise that file, or the organization keys show up as unused.

English-only apps

Most apps ship in one language. Turning i18n off is two steps:

1. In config/application.rb:

config.i18n.available_locales = [ :en ]

2. Delete the other locale files:

rm -rf config/locales/es config/locales/*.es.yml

Nothing else needs touching. The language switcher renders nothing with a single locale (in the footer and in Settings), and the CI gate stops asking for Spanish because it reads the folders that exist.

Keep the t() calls. They are not a multi-language tax: with raise_on_missing_translations on in development and test, they catch typos and missing copy in a one-language app too, and they are the reason adding a market later is a locale file instead of an archaeology project.

Upgrading an existing RailsFast app

config/application.rb and config/railsfast/railsfast.yml are protected from template updates by merge guards (see Updating RailsFast). An app that predates translations therefore receives the locale files and translated views on update, but not the i18n configuration, which leaves it with translations it cannot reach.

bin/railsfast doctor detects that state as i18n-config-missing and prints the exact block to paste into config/application.rb, with the locale list taken from the folders you actually have.