Live translation management for Rails monolith applications. A mountable engine that gives your team a full-featured dashboard to manage i18n translations — with an optional Live UI mode that lets translators click directly on any text in your running app and edit it on the spot.
No more "hey can you update this string?" Slack messages. 🎉
- Features
- Requirements
- Installation
- Configuration
- Storage Modes
- Live UI
- Dashboard
- Models & API
- Rake Tasks
- Authentication
- Custom I18n Backend
- Testing
- Contributing
- License
- 🖥️ Web Dashboard — Browse, search, and edit all your translations from a clean UI
- 🗄️ Dual Storage Modes — Keep translations in YAML files or store them in PostgreSQL with JSONB
- ✏️ Live UI — In-app inline editor: click any text, edit it, save it — without leaving the page
- 🔐 Built-in Auth — Separate translator accounts (no coupling to your
Usermodel) - 📦 Import / Export — Round-trip between YAML files and the database in one command
- ⚡ Smart Caching — Request-level, Rails cache, and memory cache layers keep things fast
- 🔗 Backend Chain — Database-first lookup with automatic fallback to your YAML files
- 🛠️ Install Generator — Interactive setup wizard, migrations included
| Requirement | Version |
|---|---|
| Ruby | >= 2.7.5 |
| Rails | >= 6.0 |
| PostgreSQL | Required for database mode only |
| SQLite | Works for file mode and development |
gem "rails_i18n_onair"bundle installThe interactive installer handles everything — storage choice, migrations, routes, and an initial account:
rails generate rails_i18n_onair:installIt will:
- Create
config/initializers/rails_i18n_onair.rb - Ask which storage mode you want (
fileordatabase) - Copy the translator migration (always needed for auth)
- Copy the translation migration (only if database mode)
- Mount the engine at
/i18nin yourroutes.rb - Offer to run
rails db:migrateright away - Create your first translator account so you can log in immediately
# Pre-select storage mode (skip the prompt)
rails generate rails_i18n_onair:install --storage-mode=database
rails generate rails_i18n_onair:install --storage-mode=file
# Skip creating the initial translator account
rails generate rails_i18n_onair:install --skip-translatorNeed to install migrations separately (e.g., in a CI pipeline)?
rails rails_i18n_onair:install:migrations:translator # auth table only
rails rails_i18n_onair:install:migrations:translation # translations table (database mode)
rails rails_i18n_onair:install:migrations:all # both at once
rails db:migrateThe generator does this automatically, but if you need to do it yourself:
# config/routes.rb
Rails.application.routes.draw do
mount RailsI18nOnair::Engine, at: "/i18n"
# ... your routes
endVisit http://localhost:3000/i18n to access the dashboard.
Configure the gem in config/initializers/rails_i18n_onair.rb:
RailsI18nOnair.configure do |config|
# :file (default) — reads/writes YAML files
# :database — reads/writes PostgreSQL JSONB
config.storage_mode = :file
# Where to find your locale YAML files (file mode only)
# Default: "config/locales"
config.locale_files_path = "config/locales"
# Cache translations in memory + Rails cache
# Strongly recommended for production
# Default: true
config.cache_translations = true
# Load each locale lazily (on first use) instead of all at startup
# Default: true
config.lazy_load_locales = true
# Enable the Live UI inline editing feature
# When true, a toolbar appears for signed-in translators
# Default: false
config.live_ui = false
endRailsI18nOnair.reset_configuration!Works with your existing config/locales/*.yml files. No database table required for translations — just the translator auth table.
Best for:
- Keeping translations in version control
- Small to medium projects
- Teams that prefer YAML as the source of truth
config.storage_mode = :file
config.locale_files_path = "config/locales"The dashboard lets you view, edit, and reload YAML files. You can also sync files to the database at any time if you want to migrate later.
Stores translations in a PostgreSQL JSONB column. Supports full CRUD from the dashboard UI with no file system access needed.
Best for:
- Large applications with many languages
- Non-technical translators who need a UI
- Dynamic, runtime-editable translations
- Multi-tenant setups
config.storage_mode = :database
config.cache_translations = trueNote: Database mode requires PostgreSQL for its JSONB column (
translation jsonb). SQLite works for tests and development (the gem uses a plainjsoncolumn in that context).
# Import all YAML files in config/locales into the database
rake rails_i18n_onair:import:allLive UI is the gem's headline feature. When enabled, a floating toolbar appears in the bottom-right corner of your app for any signed-in translator. They can toggle Edit Mode, click on any translated string, and edit it inline — all without leaving the page.
- Middleware (
RailsI18nOnair::LiveUi::Middleware) intercepts outgoing HTML responses and injects a self-contained<script>tag before</body> - Translation helper override (
RailsI18nOnair::LiveUi::TranslationHelper) is prepended toActionView::Helpers::TranslationHelper— everyt()call wraps its output in a<span>with data attributes when the Live UI is active - JavaScript renders the toolbar, popover editor, and handles
PATCHrequests to the Live Translations API - Everything is cleaned up automatically — no DOM pollution when Live UI is off
# config/initializers/rails_i18n_onair.rb
RailsI18nOnair.configure do |config|
config.live_ui = true
endOr toggle it at runtime from the dashboard under Settings → Live UI — the change takes effect immediately across all pages, with no restart:
Storage Mode and Translation Caching appear here as read-only status: both are set in config/initializers/rails_i18n_onair.rb and need an app restart to change.
When Live UI is active, every t() call produces:
<span
data-i18n-onair="true"
data-i18n-key="en.user.greeting"
data-i18n-locale="en"
style="display:contents"
>
Hello, Alice!
</span>display:contents makes the span invisible to layout — your existing CSS is unaffected. ✨
If a specific translation should never be wrapped (e.g., inside a JSON response or a meta tag):
<%= t("page.title", i18n_onair: false) %>The injected toolbar provides:
- FAB button (bottom-right, always visible) — opens the panel
- Edit Mode toggle — highlights all editable spans on the page with a dashed outline
- Click to edit — clicking any highlighted span opens a popover with a textarea
- Save / Cancel — Save POSTes the new value to the API; the page updates instantly
- Toast notifications — Success/error feedback after each save
Press Alt + Shift + E to toggle Edit Mode without reaching for the toolbar.
Access the dashboard at the mount path (default /i18n).
| Section | URL | Description |
|---|---|---|
| Dashboard | /i18n |
Overview — translation counts, languages, recent activity |
| Translations | /i18n/translations |
Full CRUD for translations (database mode) |
| Locale Files | /i18n/locale_files |
View and edit YAML files (file mode) |
| Settings | /i18n/settings |
Toggle Live UI and view current configuration |
| Login | /i18n/login |
Translator sign-in |
Shows total translation records, list of languages, and entry counts per locale. Quick Actions link straight to the translations list, the new-translation form, locale sync, and a ZIP export of every locale, while Recent Updates shows which locales changed most recently.
Shows all YAML files in the configured locale path with file sizes and modification times.
- Edit raw translation data per language
- Nested key/value tree view
- Import from YAML, export to YAML
- Reload I18n backend after changes
Handles authentication. Uses has_secure_password (bcrypt).
# Create
translator = RailsI18nOnair::Translator.create!(username: "alice", password: "secret123")
# Authenticate
translator.authenticate("secret123") # => translator
translator.authenticate("wrong") # => falseValidations:
username— required, uniquepassword— required on create, minimum 6 characters
Stores one record per language. The translation column holds the full nested JSONB hash.
# Import from a YAML file
record = RailsI18nOnair::Translation.import_from_yaml("en", "config/locales/en.yml")
# Find
record = RailsI18nOnair::Translation.load_locale("en")
# Read a nested key
record.get_translation("en.user.name") # => "Name"
# Write a nested key (persists immediately)
record.set_translation("en.user.name", "Full Name")
# Deep merge a hash into existing translations
record.merge_translations("en" => { "new_key" => "New Value" })
# Export to YAML string
yaml_string = record.export_to_yaml
# Class-level helpers
RailsI18nOnair::Translation.available_languages # => ["en", "fr", "es"]
RailsI18nOnair::Translation.locale_exists?("en") # => true
RailsI18nOnair::Translation.lookup_key("en", "en.user.name") # => "Name"
RailsI18nOnair::Translation.load_locales(["en", "fr"]) # => { "en" => {...}, "fr" => {...} }Used internally by the Live UI JavaScript. Available to any HTTP client too.
PATCH /i18n/api/live_translations/:localeRequest body (JSON):
{
"key": "user.greeting",
"value": "Hello, friend!"
}Response:
{ "status": "ok" }Errors:
| Status | Condition |
|---|---|
403 Forbidden |
Live UI is disabled in configuration |
422 Unprocessable Entity |
Blank key, or locale not found in storage |
302 Redirect |
Not authenticated |
# Import all locale YAML files into the database
rake rails_i18n_onair:import:all
# Import a single language
rake rails_i18n_onair:import:language[en]
rake rails_i18n_onair:import:language[pt-BR]Supported filename patterns: en.yml, fr.yml, es-MX.yml, pt-BR.yml
Files that don't match the [language].yml convention are skipped with a note.
Example output:
Importing locale files from: config/locales
================================================================================
Import Summary:
Imported: 3 file(s)
Skipped: 1 file(s)
Errors:
- Skipped application.en.yml: Invalid file name format
================================================================================
Import completed!
rake rails_i18n_onair:install:migrations:translator # translator auth table
rake rails_i18n_onair:install:migrations:translation # translations JSONB table
rake rails_i18n_onair:install:migrations:all # bothThe gem ships its own authentication system — completely separate from your app's User model. There's no Devise, no Warden, just a session cookie and bcrypt.
Via the install generator (prompted automatically), or manually:
RailsI18nOnair::Translator.create!(username: "alice", password: "secret123")GET /i18n/login— login formPOST /i18n/login— setssession[:translator_id]DELETE /i18n/logout— clears the session- All dashboard routes run
before_action :authenticate_translator!and redirect to the login page if not signed in
The middleware reads session[:translator_id] straight from the Rack env cookie, then confirms that translator still exists so a deleted account loses Live UI access. That lookup is cached for 60 seconds, so it does not add a query per request, and if the model cannot be reached at all the signed session is trusted rather than silently disabling Live UI. The resulting flag is stored in RailsI18nOnair::Current.live_ui_active (a CurrentAttributes attribute) and is reset automatically at the end of each request.
When storage_mode: :database is configured and the translations table exists, the gem installs a custom I18n::Backend::Chain:
RailsI18nOnair::DatabaseBackend → file backend (your YAML files)
This means:
- Translations found in the database are returned first
- Keys missing from the database fall back to your YAML files transparently
- Removing a translation from the database automatically falls back to the file
The backend uses three cache layers (fast → slow):
- Request cache —
RailsI18nOnair::Current.translation_cache(a plain hash, reset each request) - Memory cache —
@memory_cacheon the backend instance - Rails cache —
Rails.cache.fetch("i18n_onair:locale:#{locale}", expires_in: 1.hour)
All layers are cleared when you save a translation from the dashboard or the Live UI.
I18n.backend.reload! # Clear all locales
I18n.backend.reload_locale(:en) # Clear just one localeOr use the dashboard: Locale Files → Reload Backend.
The gem has a full RSpec test suite that runs without a dummy Rails app — components are loaded directly for speed.
bundle exec rspecspec/
├── spec_helper.rb # Minimal RSpec config
├── rails_helper.rb # DB setup, model loading, transaction rollback
├── unit/
│ ├── configuration_spec.rb
│ ├── current_spec.rb
│ ├── file_manager_spec.rb
│ ├── importer_spec.rb
│ └── live_ui/
│ ├── middleware_spec.rb
│ ├── script_spec.rb
│ └── translation_helper_spec.rb
├── models/
│ ├── translation_spec.rb
│ └── translator_spec.rb
└── controllers/
├── settings_controller_spec.rb
└── api/
└── live_translations_controller_spec.rb
Tests use SQLite in-memory with ActiveRecord::Rollback for isolation — no database cleanup gems needed.
| Column | Type | Notes |
|---|---|---|
id |
bigint | primary key |
username |
string | NOT NULL, unique |
password_digest |
string | NOT NULL |
created_at |
datetime | |
updated_at |
datetime |
| Column | Type | Notes |
|---|---|---|
id |
bigint | primary key |
language |
string | NOT NULL, unique (e.g. "en", "pt-BR") |
translation |
jsonb | NOT NULL, default {} |
created_at |
datetime | |
updated_at |
datetime |
Indexes: unique on language, GIN on translation (fast JSONB key lookup).
git clone https://github.com/tarqmamdouh/rails-i18n-onair
cd rails-i18n-onair
bundle install
bundle exec rspecThe test suite uses SQLite in-memory — no PostgreSQL needed locally. For a production-parity test of the JSONB features, set DATABASE_URL to a Postgres instance before running specs.
The Gemfile resolves different dependency sets per Ruby version, because Rails 8 and the current sqlite3, minitest and nokogiri releases all require Ruby >= 3.2 while the gem itself still supports 2.7.2:
| Ruby | Resolves to |
|---|---|
| >= 3.2 | Rails 8.x, sqlite3 2.x |
| 3.0 – 3.1 | Rails 7.0, sqlite3 1.x |
| 2.7 | Rails 7.0, sqlite3 < 1.7 |
The suite passes on all of these. Gemfile.lock is not checked in, so run bundle install after switching Ruby versions.
bin/release patch # 1.0.2 -> 1.0.3
bin/release minor # 1.0.2 -> 1.1.0
bin/release 1.2.3 # explicit version
bin/release patch --dry-run # run the checks, change nothingThe script runs the preflight checks (clean tree, on main, tag not already taken, RSpec green), bumps lib/rails_i18n_onair/version.rb, moves the CHANGELOG's Unreleased entries into a dated section, then commits, tags and pushes.
It stops there: publishing is handled by .github/workflows/gem-push.yml, which builds the gem and pushes it to GitHub Packages and RubyGems when a v* tag lands. Bumping the version before tagging is the part that must not be skipped — RubyGems rejects a re-push of an existing version, so a tag placed without a version bump fails at the publish step.
Bug reports and pull requests are welcome on GitHub at github.com/tarqmamdouh/rails-i18n-onair
- Fork the repo
- Create a feature branch (
git checkout -b feature/my-feature) - Add tests for your changes (the suite already has 176 examples — let's keep that green 🟢)
- Open a pull request
Released under the MIT License.



