BeMyWords with Rails i18n
A complete integration guide for Rails applications using the standard I18n backend and YAML locale files.
Shape of the integration:
- At deploy time (or periodically), fetch translations from BeMyWords and write them as
config/locales/<lang>.yml. - Rails's built-in
I18n.treads those YAML files as normal. - Optionally, push your master English YAML to BeMyWords so translators work from a canonical source.
BeMyWords stores translations as flat key/value JSON. Rails expects nested YAML keyed by locale. This guide handles the conversion in both directions.
Before and after
The point of this integration is that you stop maintaining non-English locale files by hand. Concretely:
Before — every language is a file in your repo that a developer edits:
config/locales/
├── en.yml ← you add a key here
├── nb.yml ← …then remember to add it here
├── sv.yml ← …and here
└── de.yml ← …and here, usually with a TODO or the English string
# A new string means a pull request touching every locale file, and a
# separate round-trip to whoever actually speaks the language.
t("billing.invoice.overdue_notice")
# => "translation missing: nb.billing.invoice.overdue_notice"
After — English is the only locale file you write. The rest are build artifacts:
config/locales/
├── en.yml ← the only file you edit (or push from BeMyWords)
├── nb.yml ← generated by rake bemywords:fetch, gitignored
├── sv.yml ← generated
└── de.yml ← generated
# Add the key once, in English. The next deploy fetches every other
# language — already translated, reviewed in BeMyWords, with the
# glossary and do-not-translate terms applied.
t("billing.invoice.overdue_notice")
# => "Faktura forfalt til betaling"
The diff in your deploy configuration is one line:
release: |
bundle exec rails db:migrate
+ bundle exec rails bemywords:fetch
Nothing changes in your application code: I18n.t still reads YAML from config/locales, and there is no runtime dependency on BeMyWords. If the fetch fails, the previous YAML is still on disk and the app keeps serving the last known-good translations.
Prerequisites
- A Rails app with
I18nalready wired up (the Rails default). - A BeMyWords workspace, project, and namespace.
- A BeMyWords API key scoped to the project.
1. Environment variables
Add to .env (or your Rails.application.credentials if you prefer):
BEMYWORDS_BASE_URL=https://www.bemywords.no
BEMYWORDS_PROJECT_ID=<your-project-uuid>
BEMYWORDS_API_TOKEN=<your-api-token>
BEMYWORDS_NAMESPACE=app
2. Fetch translations via a Rake task
Create lib/tasks/bemywords.rake:
require "net/http"
require "json"
require "yaml"
namespace :bemywords do
desc "Fetch translations from BeMyWords and write config/locales/<lang>.yml"
task fetch: :environment do
base_url = ENV.fetch("BEMYWORDS_BASE_URL", "https://www.bemywords.no")
project_id = ENV.fetch("BEMYWORDS_PROJECT_ID")
token = ENV.fetch("BEMYWORDS_API_TOKEN")
namespace = ENV.fetch("BEMYWORDS_NAMESPACE", "app")
languages = I18n.available_locales.map(&:to_s)
languages.each do |lang|
uri = URI("#{base_url}/api/#{project_id}/latest/#{lang}/#{namespace}")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Token token=#{token}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(req)
end
unless res.is_a?(Net::HTTPSuccess)
warn " [#{lang}] HTTP #{res.code} — skipping"
next
end
flat = JSON.parse(res.body)
# BeMyWords uses dot-separated flat keys ("user.login.title").
# Rails YAML expects nested hashes under the locale code.
nested = flat.each_with_object({}) do |(key, value), hash|
parts = key.split(".")
cursor = hash
parts[0...-1].each do |part|
cursor[part] ||= {}
cursor = cursor[part]
end
cursor[parts.last] = value
end
out_path = Rails.root.join("config", "locales", "#{lang}.yml")
File.write(out_path, { lang => nested }.to_yaml)
puts " [#{lang}] wrote #{flat.size} keys to #{out_path.relative_path_from(Rails.root)}"
end
end
end
Add the generated files to .gitignore if you want them rebuilt on every deploy:
/config/locales/en.yml
/config/locales/nb.yml
Or keep them committed and update via CI — whichever matches your workflow.
3. Run it
bundle exec rails bemywords:fetch
After that, use I18n.t as you normally would:
# In a controller, view, or anywhere
I18n.t("user.login.title")
4. Wire it into your build / deploy
Heroku (release phase)
In Procfile:
release: bundle exec rails db:migrate bemywords:fetch
Any CI platform
Run bundle exec rails bemywords:fetch as a build step before assets:precompile.
Runtime fetch (not recommended)
If you want runtime fetching (translations change without a deploy), you'd typically use an on-disk cache plus a background refresh job. Out of scope for this guide — the build-time pattern is simpler, faster, and cheaper.
5. (Optional) Push English master to BeMyWords
To sync your English YAML to BeMyWords so translators have a canonical source:
# Add to lib/tasks/bemywords.rake
desc "Push English YAML to BeMyWords as master source"
task push_english: :environment do
base_url = ENV.fetch("BEMYWORDS_BASE_URL", "https://www.bemywords.no")
project_id = ENV.fetch("BEMYWORDS_PROJECT_ID")
token = ENV.fetch("BEMYWORDS_API_TOKEN")
namespace = ENV.fetch("BEMYWORDS_NAMESPACE", "app")
en_yaml = YAML.load_file(Rails.root.join("config", "locales", "en.yml"))
nested = en_yaml["en"]
# Flatten Rails-style nested YAML to BeMyWords-style dot-separated keys.
flat = {}
flatten = lambda do |h, prefix|
h.each do |k, v|
key = prefix.empty? ? k.to_s : "#{prefix}.#{k}"
if v.is_a?(Hash)
flatten.call(v, key)
else
flat[key] = v.to_s
end
end
end
flatten.call(nested, "")
uri = URI("#{base_url}/api/overwrite/#{project_id}/latest/en/#{namespace}")
req = Net::HTTP::Put.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Token token=#{token}"
req.body = { translations: flat }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(req)
end
if res.is_a?(Net::HTTPSuccess)
puts "Pushed #{flat.size} English keys to BeMyWords."
else
warn "Push failed: HTTP #{res.code} — #{res.body}"
exit 1
end
end
Run when you've changed English master strings:
bundle exec rails bemywords:push_english
6. Handling Rails-specific features
Pluralization
Rails i18n handles plurals with CLDR plural categories:
en:
apples:
one: "1 apple"
other: "%{count} apples"
In BeMyWords, store each plural form as a separate dot-key:
{
"apples.one": "1 apple",
"apples.other": "%{count} apples"
}
The fetch task nests these back under apples on output. No changes to how you call I18n.t("apples", count: 3).
Variable interpolation
Rails uses %{name} placeholders. BeMyWords stores strings verbatim — placeholders pass through unchanged.
en:
greeting: "Hello, %{name}!"
Use placeholder validation in your BeMyWords project settings to catch translators who drop or mangle %{name}.
Namespaces: per-engine or per-feature
If your app has engines or large feature silos, use separate BeMyWords namespaces per engine. Set BEMYWORDS_NAMESPACE dynamically in the rake task, or use multiple rake tasks (bemywords:fetch_main, bemywords:fetch_admin, etc.).
API reference
See the Astro integration guide — the API surface is identical, only the client differs.
Troubleshooting
KeyError: key not found: "BEMYWORDS_PROJECT_ID": env var not set. On Heroku, heroku config:set BEMYWORDS_PROJECT_ID=....
I18n::MissingTranslation in views after fetch: check the generated YAML structure. The flat-to-nested conversion in the rake task assumes dot-separated keys in BeMyWords correspond directly to the nested Rails structure. If you have dots in actual key names (rare), the conversion needs adjustment.
Pluralization shows translation missing: …: ensure all plural forms (one, other, plus few, many etc. for locales that need them) exist in BeMyWords for the target language.
Rails won't start because a YAML file is malformed: usually means the fetch ran with garbage data. Check the raw response from the API manually with curl.