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 with GitHub Actions.
Demo repository
Clone or fork lingodotdev/ruby-on-rails-localization-example to follow along. The repository contains a working Rails app with config/locales YAML files, a Lingo.dev CLI configuration, and a GitHub Actions workflow.
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.
| Layer | What lives there | Example file |
|---|---|---|
| UI strings | Buttons, labels, flash messages | config/locales/en.yml |
| Mailer copy | Subjects and bodies for ActionMailer | config/locales/mailers.en.yml |
| Model errors | Validation messages and attribute names | config/locales/activerecord.en.yml |
The first key of every Rails YAML file is the locale code itself – en:, es:, fr:. The CLI's yaml-root-key bucket understands this shape: it strips the locale prefix before sending content to your localization engine, then writes a parallel file with the target locale code as the new root key. Nested keys, %{name} interpolation tokens, and CLDR plural categories (zero/one/two/few/many/other) are preserved.
Prerequisites#
Create a localization engine
Every CLI run sends content through a localization engine – the configuration that determines which LLM model, glossary, brand voice, and instructions apply. Create one in the Lingo.dev dashboard and generate an API key.
Verify Ruby and Rails
This guide targets Rails 7.2 or higher, which requires Ruby 3.1 or higher. Check your versions:
ruby -v
rails -vVerify Node.js
The CLI requires Node.js 18 or higher:
node -vSet 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:
<h1>Welcome</h1>with:
<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:
config/locales/
en.yml # Source locale
es.yml # Generated by Lingo.dev
fr.yml
de.ymlA typical en.yml mixes plain strings, nested namespaces, %{name} interpolation, and pluralization:
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#
Create an i18n.json file in your project root. Declare a yaml-root-key bucket pointing at the locale files:
{
"$schema": "https://lingo.dev/schema/i18n.json",
"version": "1.15",
"locale": {
"source": "en",
"targets": ["es", "fr", "de"]
},
"buckets": {
"yaml-root-key": {
"include": ["config/locales/[locale].yml"]
}
}
}The [locale] placeholder resolves to each configured locale code. With source: "en", the CLI reads config/locales/en.yml and writes translated files to config/locales/es.yml, config/locales/fr.yml, and config/locales/de.yml.
Rails happily loads mailers.en.yml, pages.en.yml, and so on alongside en.yml. Add additional patterns to the include array of your yaml-root-key bucket:
{
"buckets": {
"yaml-root-key": {
"include": [
"config/locales/[locale].yml",
"config/locales/mailers.[locale].yml",
"config/locales/pages.[locale].yml"
]
}
}
}Configure Rails for Multiple Locales#
Tell Rails which locales are available and which one to use as the default. In config/application.rb:
module YourApp
class Application < Rails::Application
config.i18n.available_locales = [:en, :es, :fr, :de]
config.i18n.default_locale = :en
config.i18n.fallbacks = [:en]
end
endPick the request locale in ApplicationController from a URL parameter or the Accept-Language header:
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
endRender 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:
<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:
<nav>
<% I18n.available_locales.each do |locale| %>
<%= link_to locale.upcase, url_for(locale: locale) %>
<% end %>
</nav>Translate Locally#
Set your API key and run the CLI:
export LINGO_API_KEY="your-api-key"
npx lingo.dev@latest runThe CLI reads every file matching your bucket patterns, identifies untranslated entries using the lockfile, translates the delta through your localization engine, and writes results into each target locale file. The locale root key, nested namespaces, %{name}-style interpolation tokens, and plural categories are preserved – only translatable text changes.
To target a specific locale during development:
npx lingo.dev@latest run --target-locale esRestart the Rails server after the first translation run so the new YAML files load:
bin/rails serverVisit /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:
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 with GitHub Actions#
Add a workflow file at .github/workflows/translate.yml to translate on every push:
Translations commit directly to main – zero friction, ideal for small teams:
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}Store your API key as LINGODOTDEV_API_KEY in Settings > Secrets and variables > Actions in your GitHub repository.
Verify Before Deploy#
Use the --frozen flag as a deployment gate to ensure no untranslated content ships to production. The CLI exits with a non-zero status if any entries need translation:
npx lingo.dev@latest run --frozenAdd this as a separate CI step before your asset precompile or container build:
- name: Verify translations
run: npx lingo.dev@latest run --frozen
- name: Precompile assets
run: bundle exec rails assets:precompile