|
Documentation
Book a DemoPlatform
PlatformMCPCLIAPIWorkflows
Guides
Changelog

Localization

  • Overview
  • Translation API
  • Web App Localization
  • Mobile App Localization
  • iOS with String Catalogs
  • Android with strings.xml
  • Emails Localization
  • Static Content (e.g. .md, .json)
  • Next.js with Markdoc
  • Rails with i18n

Workflows

  • Engine Setup with MCP
  • Jira Triage
  • CI/CD

Ruby on Rails Localization with the i18n API

The Lingo.dev CLI translates Rails config/locales YAML files through a configured localization engine. Rails ships with the i18n API baked in – your app's translatable text lives in per-locale YAML files. Lingo.dev fits the existing pipeline without adding a runtime dependency.

This guide walks through localizing a Rails app end-to-end: configuring the CLI, organizing per-locale YAML files, switching locales at request time, and automating translations in CI.

Demo repository

Clone or fork lingodotdev/ruby-on-rails-localization-example to follow along. It's a working Rails app with config/locales YAML files, a committed .lingo/config.json, and translations already in place, so you can read the config next to the output it produced.

How Rails Localization Works#

Rails reads translations from YAML files under config/locales/. Each file is keyed by a locale code at the root and contains nested keys that mirror the lookup paths your code uses with I18n.t.

LayerWhat lives thereExample file
UI stringsButtons, labels, flash messagesconfig/locales/en.yml
Mailer copySubjects and bodies for ActionMailerconfig/locales/mailers.en.yml
Model errorsValidation messages and attribute namesconfig/locales/activerecord.en.yml

The first key of every Rails YAML file is the locale code itself – en:, es:, fr:. Rails keys translations by that root key rather than by the filename: it loads every file under config/locales/ and stores each one's contents under whatever root it declares. So an es.yml still rooted at en: is not ignored – it is merged into the en namespace. Spanish ends up with no translations at all, and the English ones get quietly overwritten.

Translating this format therefore means rewriting that key, not just the values. The yaml-root-key format does exactly that: it walks the tree below the root key, translates only string values, and writes the target file rooted at the target locale. Nested keys, %{name} interpolation tokens, and CLDR plural categories (zero/one/two/few/many/other) are structure, so they carry through untouched – as do comments and YAML anchors.

Prerequisites#

1

Create a localization engine

Every CLI run sends content through a localization engine – the configuration that determines which LLM model, glossary, brand voice, and rules apply. Create one in the Lingo.dev dashboard and generate an API key.

2

Verify Ruby and Rails

This guide targets Rails 7.2 or higher, which requires Ruby 3.1 or higher. Check your versions:

bash
ruby -v
rails -v
3

Verify Node.js

The CLI requires Node.js 22 or higher:

bash
node -v
4

Set up Rails i18n

This guide assumes your app already stores translations in config/locales/*.yml. If you have hardcoded strings in views or controllers, extract them into t() calls first. For example, replace:

erb
<h1>Welcome</h1>

with:

erb
<h1><%= t(".welcome") %></h1>

then add the matching key to config/locales/en.yml. See Rails internationalization guide for the full migration steps.

Organize Translation Files#

Rails auto-loads every *.yml file under config/locales/. Keep the source locale next to its translated siblings so the directory acts as the single source of truth:

text
config/locales/
  en.yml          # Source locale
  es.yml          # Generated by Lingo.dev
  fr.yml
  de.yml

A typical en.yml mixes plain strings, nested namespaces, %{name} interpolation, and pluralization:

yaml
en:
  hello: "Hello"
  home:
    welcome: "Welcome, %{name}!"
    cta: "Get started"
  notifications:
    unread:
      zero: "No unread notifications"
      one: "1 unread notification"
      other: "%{count} unread notifications"
  errors:
    messages:
      blank: "can't be blank"

Configure the CLI#

Install the CLI, authenticate, and link the project to your engine:

bash
npm install -g @lingo.dev/cli
lingo login
lingo init
lingo link

lingo init and lingo link create .lingo/config.json with your locales and your engine's orgId and engineId. Commit it, along with .lingo/lock.json, so every machine and every CI run share the same state.

Point a files[] entry at the source locale file and set the format explicitly:

json
{
  "orgId": "org_...",
  "engineId": "eng_...",
  "sourceLocale": "en",
  "targetLocales": ["es", "fr", "de"],
  "files": [{ "pattern": "config/locales/en.yml", "format": "yaml-root-key" }]
}

"format": "yaml-root-key" is required here, not optional. A .yml path cannot reveal whether its root key is a locale or ordinary configuration, so the CLI does not try to guess: omit format and the file is treated as generic yaml, which translates the values and leaves the root at en: – the silent failure described above.

Targets are derived from the source path, so config/locales/en.yml produces config/locales/es.yml, config/locales/fr.yml, and config/locales/de.yml.

Rails also loads per-concern files alongside en.yml – devise.en.yml, mailers.en.yml, activerecord.en.yml. Add a second entry with a glob to cover them:

json
{
  "files": [
    { "pattern": "config/locales/en.yml", "format": "yaml-root-key" },
    { "pattern": "config/locales/*.en.yml", "format": "yaml-root-key" }
  ]
}

The two patterns don't overlap: the first matches only en.yml, and the second matches only files ending in .en.yml, so already-translated files like es.yml and devise.es.yml are never picked up as sources. devise.en.yml produces devise.es.yml.

Configure Rails for Multiple Locales#

Tell Rails which locales are available and which one to use as the default. In config/application.rb:

ruby
module YourApp
  class Application < Rails::Application
    config.i18n.available_locales = [:en, :es, :fr, :de]
    config.i18n.default_locale = :en
    config.i18n.fallbacks = [:en]
  end
end

Pick the request locale in ApplicationController from a URL parameter or the Accept-Language header:

ruby
class ApplicationController < ActionController::Base
  around_action :switch_locale

  private

  def switch_locale(&action)
    locale = params[:locale] || http_accept_locale || I18n.default_locale
    I18n.with_locale(locale, &action)
  end

  def http_accept_locale
    header = request.headers["Accept-Language"].to_s
    header.scan(/[a-z]{2}/).find { |l| I18n.available_locales.map(&:to_s).include?(l) }
  end

  def default_url_options
    { locale: I18n.locale }
  end
end

Render Translations in Views#

Use t and l helpers in ERB templates. A leading dot in the key resolves against the current view path, keeping translation keys colocated with the templates that use them:

erb
<h1><%= t(".welcome", name: @user_name) %></h1>
<p><%= t("notifications.unread", count: @unread_count) %></p>
<%= link_to t(".cta"), signup_path, class: "btn-primary" %>

Add a locale switcher to your layout:

erb
<nav>
  <% I18n.available_locales.each do |locale| %>
    <%= link_to locale.upcase, url_for(locale: locale) %>
  <% end %>
</nav>

Translate Locally#

bash
lingo push --wait

push uploads the source files, runs them through your localization engine, and writes the translated files back. --wait keeps the command blocking until the outputs land – worth passing explicitly, because a pending release changes the default so push submits the run and returns immediately, leaving you to collect results with lingo pull.

Later runs translate only the delta: push hashes the source and compares it against .lingo/lock.json, so unchanged entries cost nothing. The first run on a project – or after adding a locale – needs the whole corpus:

bash
lingo push --backfill-missing

Scope a run to a subset of files by passing a glob:

bash
lingo push "config/locales/**"

To fetch outputs produced somewhere else – another machine, or CI – run lingo pull.

Restart the Rails server after the first translation run so the new YAML files load:

bash
bin/rails server

Visit /es to see the Spanish output.

Plurals#

Rails uses CLDR plural categories – zero, one, two, few, many, other. Pass a count: argument to I18n.t and Rails picks the matching key:

ruby
t("notifications.unread", count: 0)   # => "No unread notifications"
t("notifications.unread", count: 1)   # => "1 unread notification"
t("notifications.unread", count: 12)  # => "12 unread notifications"

The CLI translates each plural variant in place. If your target locale needs more categories than English's one/other, define them in your source en.yml.

Automate in CI#

The Lingo.dev GitHub App translates on every push and pull request, server-side – no runner, and no API key stored in your repository. It resolves the engine from the committed .lingo/config.json, so orgId and engineId need to be present in the file you commit.

If you'd rather run the CLI in your own pipeline – GitHub Actions, GitLab CI, Bitbucket Pipelines – see CI/CD Workflows. Provide LINGO_API_KEY as a secret and call lingo push --wait like any other build step.

Verify Before Deploy#

Use lingo check as a deployment gate so no untranslated content ships to production. It exits with a non-zero status if any entries still need translation, and writes nothing:

bash
lingo check

Add it as a separate CI step before your asset precompile or container build:

yaml
- name: Verify translations
  run: lingo check
- name: Precompile assets
  run: bundle exec rails assets:precompile

Next Steps#

Static Content Localization
Markdown, MDX, JSON, YAML, and other static file formats
Web App Localization
UI string patterns across common web frameworks
CI/CD Workflows
GitHub Actions, GitLab CI, Bitbucket Pipelines patterns
Glossaries
Lock brand names and technical terms from translation

Was this page helpful?

Max PrilutskiyMax Prilutskiy·Updated 12 days ago·6 min read