Custom translations per tenant in a Rails app thanks to I18n backends
When building a SaaS or any multi-tenant application, it is common to want to adapt certain translation keys for certain customers, in order to provide a truly customized experience. In this article, we'll see a very powerful yet easy way to achieve it thanks to I18n backends in any Rails app.
The i18n lib in ruby works with "backends". Given a specific key and locale, they are tasked with retrieving the correct translation key.
The default backend is I18n::Backend::Simple
, which looks at the translations in YML files stored in config/locales, but it is
possible to create custom backends and even chain them to find translations in different places with a sense of priority.
Identifying the current customer / tenant
Rails provide us with the great Current
class that allows us to store variables scoped to the current HTTP request.
If your application is already multi-tenant capable, you probably already have something similar :
# app/models/current.rb class Current < ActiveSupport::CurrentAttributes attribute :tenant attribute :user end # app/controllers/application_controller.rb class ApplicationController < ActionController::Base before_action :set_current_tenant # ... def set_current_tenant Current.tenant = current_user.tenant end end
Solution 1: store custom keys in the YML files directly
The simplest approach is to store the custom translations along the others in the yaml, but with a prefix.
For example, we can imagine using tenant_%{id}
as a prefix, so when looking for the key en.dashboard.index.title
we will first try to look into en.tenant_%{id}.dashboard.index.title
We can achieve this by extending the default I18n backend so that it will first look for the translation in the custom scope, and return it if found.
This can be done by overloading the translate
method :
# config/initializers/i18n.rb Rails.application.to_prepare do class I18nTenantPrefix < I18n::Backend::Simple def translate(locale, key, options = {}) return super unless Current.tenant.present? tenant_key = "#{Current.tenant.id}_#{key}" return super unless exists?(locale, tenant_key, options) super(locale, tenant_key, options) end end I18n.backend = I18nTenantPrefix.new end
This is a quick and efficient way to customize keys for our different tenants. To manage the translations in the YML file, we can use a tool like YAMLFish to allow anyone in the team to work on those translations.
Solution 2: store custom keys in the database
If we want the custom keys to be instantly available after update, without needing to redeploy the app, we can store them in the database. We can achieve this thanks to a special I18n backend that we can chain with the default one.
Writing an i18n backend means implementing a class that inherits I18n::Backend::Base
and implements missing methods to store translations from arbitrary data (usually in memory) and translate a given key.
This can be tedious but thankfully the i18n lib provide us with a backend named I18n::Backend::KeyValue
which accepts a store class for which we only have to define 2 methods :
Store#[key]
that should return the translation for the given keyStore#keys
that should return all possible keys that the backend can handle
Then we can chain the default simple backend with our custom one, to provide a fallback mechanism :
# config/initializers/i18n.rb Rails.application.config.to_prepare do class CurrentTenantTranslationsStore def [](key) Current.tenant&.custom_keys&.dig(key)&.to_json end def keys Current.tenant&.custom_keys&.keys || [] end end I18n.backend = I18n::Backend::Chain.new( I18n::Backend::KeyValue.new(CurrentTenantTranslationsStore.new), I18n::Backend::Simple.new ) end
This provides a very easy way to customize translations for each tenant.