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 key
  • Store#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.

Conclusion

I18n backends are a powerful way to "hack" into the way translations are fetched, allowing, in our case, to provide custom translations per tenant.

Made with ✨ in 🇫🇷 by Adrien Siami

Need anything ? [email protected] · About · Roadmap