Skip to content

Localize a Ruby on Rails app: complete guide with Localingos

Rails' i18n system (i18n gem, ships built in) is the gold standard for Ruby web app localization — YAML files, interpolation, pluralization, scoped lookups, all in the core framework. The gap is the translation workflow: who maintains es.yml, de.yml, fr.yml as English copy evolves? This guide pairs Rails' built-in i18n with Localingos to automate the translation step.

Step 1 — Install

npm install -g localingos

The Rails i18n gem is included by default — no Gemfile changes needed.

Step 2 — Configure locales

config/application.rb:

module YourApp
  class Application < Rails::Application
    config.i18n.default_locale = :en
    config.i18n.available_locales = [:en, :es, :de, :fr, :ja, :'pt-BR', :'zh-CN', :ko]
    config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.yml')]
    config.i18n.fallbacks = true
  end
end

fallbacks = true means missing keys silently fall through to English instead of raising — good for production safety.

Step 3 — Source of truth

config/locales/en.yml:

en:
  welcome: "Welcome, %{name}"
  cart:
    zero: "Your cart is empty"
    one: "1 item in your cart"
    other: "%{count} items in your cart"
  errors:
    not_found: "We couldn't find what you were looking for"

Rails interpolation uses %{var} syntax; pluralization uses the zero/one/other keys under a scope.

Step 4 — Configure Localingos

Run localingos init and answer its prompts. It writes two files.

localingos.config.json — commit this. Project settings shared with your team and CI:

{
  "projectId": "your-project-id",
  "sourceLocale": "en-US",
  "format": "yaml-rails",
  "sourceFile": "config/locales/en.yml",
  "outputDir": "config/locales",
  "outputPattern": "{locale}.yml"
}

.localingos.json — add this to .gitignore. Your API key for local development:

{ "apiKey": "your-api-key" }

In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.

The CLI reads and writes YAML directly, so there is no conversion step: your catalogue stays in the format your framework already expects.

Things worth knowing:

  • Use yaml-rails, not yaml. Rails catalogues hang everything off a locale root key (en: at the top). The yaml-rails format strips that root when reading — so your ids are home.title, not en.home.title — and restores it with the target locale when writing. Plain yaml is for YAML that is not locale-rooted.
  • %{var} interpolation is preserved automatically. Placeholder detection is server-side; there is nothing to configure.
  • Rails pluralization keys (one, other, few…) are ordinary nested keys. Translation returns the forms you send, so author every category your target languages need rather than expecting them to appear.
  • Target locales are not configured here. They belong to the project — set them in the dashboard under ProjectsEditLocalesUpdate Project. Codes are full BCP 47, e.g. es-ES, pt-BR, el-GR.
  • Placeholder preservation is automatic. Localingos extracts the placeholders in your source string and validates each one survives translation, retrying with a corrective prompt when it doesn't.

Translation is asynchronous

The first sync of a new key pushes it and usually has nothing to pull back yet:

Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation
No new translations. Run "localingos sync" again later.

That's expected. Run it again shortly to collect results. Verify completeness before committingpull writes whatever is ready and exits 0, so a catalogue pulled mid-translation can be missing keys with no warning.

Then push your source strings and pull back translations:

localingos sync

Translation is asynchronous

The first sync of a new key pushes it and usually has nothing to pull back yet:

Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation
No new translations. Run "localingos sync" again later.

That's expected. Run it again shortly to collect results. Verify completeness before committingpull writes whatever is ready and exits 0, so a catalogue pulled mid-translation can be missing keys with no warning.

Two Rails-specific things:

  • format: "yaml" — Localingos parses Rails-style YAML (with the top-level locale key) and writes the target files with the correct top-level key per locale.
  • Rails's %{var} interpolation is preserved automatically. Placeholder detection is server-side; there is no placeholders setting to configure.

All target .yml files appear in config/locales/. Commit them.

Step 5 — Use in views

<header>
  <h1><%= t('welcome', name: current_user.name) %></h1>
  <p><%= t('cart', count: cart.item_count) %></p>
</header>

Rails picks the right plural variant from your YAML based on the count: argument.

Step 6 — Use in controllers and models

class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    if @order.save
      flash[:notice] = t('orders.created')
      redirect_to @order
    else
      flash[:alert] = t('orders.create_failed')
      render :new
    end
  end
end

For ActiveRecord validation messages, Rails has dedicated YAML scopes:

en:
  activerecord:
    attributes:
      user:
        email: "Email"
    errors:
      models:
        user:
          attributes:
            email:
              blank: "is required"
              taken: "is already in use"

Localingos preserves these nested scopes exactly when translating.

Step 7 — URL routing per locale

config/routes.rb:

Rails.application.routes.draw do
  scope "(:locale)", locale: /es|de|fr|ja|pt-BR|zh-CN|ko/ do
    resources :products
    get 'pricing', to: 'pages#pricing'
    get 'docs', to: 'pages#docs'
  end

  root 'pages#home'
end

ApplicationController:

class ApplicationController < ActionController::Base
  before_action :set_locale

  def set_locale
    I18n.locale = params[:locale] || extract_locale_from_accept_language || I18n.default_locale
  end

  def default_url_options
    { locale: I18n.locale == I18n.default_locale ? nil : I18n.locale }
  end

  private

  def extract_locale_from_accept_language
    request.env['HTTP_ACCEPT_LANGUAGE']&.scan(/^[a-z]{2}/)&.first if I18n.available_locales.map(&:to_s).include?(request.env['HTTP_ACCEPT_LANGUAGE']&.scan(/^[a-z]{2}/)&.first)
  end
end

Now /pricing is English, /es/pricing is Spanish. default_url_options ensures generated links preserve the current locale.

Step 8 — hreflang in layouts

app/views/layouts/application.html.erb:

<head>
  <%= csrf_meta_tags %>
  <link rel="canonical" href="<%= canonical_url %>">
  <% I18n.available_locales.each do |loc| %>
    <link rel="alternate" hreflang="<%= loc %>" href="<%= alternate_url_for(loc) %>">
  <% end %>
  <link rel="alternate" hreflang="x-default" href="<%= canonical_url %>">
</head>

Helper methods in application_helper.rb:

def canonical_url
  url_for(only_path: false, locale: nil)
end

def alternate_url_for(locale)
  url_for(only_path: false, locale: locale == I18n.default_locale ? nil : locale)
end

Step 9 — Automate sync in CI

# .github/workflows/i18n.yml
name: i18n-sync
on:
  push: { branches: [main], paths: ['config/locales/en.yml'] }
jobs:
  sync:
    runs-on: ubuntu-latest
    permissions: { contents: write, pull-requests: write }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install -g localingos
      - run: localingos sync
        env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
      - uses: peter-evans/create-pull-request@v6
        with:
          branch: i18n/auto-sync
          title: 'chore(i18n): sync translations'
          commit-message: 'chore(i18n): sync translations'

Production checklist

  • YAML files in git, not generated at deploy time. Translations should be visible in PRs.
  • I18n.t vs I18n.t!. Use t! in tests to fail loudly on missing keys; use t in production for graceful fallback.
  • Locale-aware date/number formatting. Rails has l(Time.now) for dates and number_to_currency for money — both use the active locale.
  • Mailer i18n. class WelcomeMailer < ApplicationMailer — wrap mailer actions in I18n.with_locale(user.locale) { ... } so transactional emails go out in the recipient's language.

Wrap up

Rails' i18n + Localingos = a multilingual Rails app with locale-prefixed routes, hreflang, ActiveRecord error messages translated, and a CI-driven translation pipeline. The setup follows Rails conventions exactly — no monkey patches, no replacement libraries.

Free tier: 5,000 words, counted once per target locale — so a small Rails app in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.