Previewing i18n integration in Hanami 3.0

Hello Hanami friends! Here’s an overview of our built-in i18n support for Hanami.

i18n is an important feature of apps and I’m excited for us to make this as easy as possible for our users, while still providing all the flexibility people come to expect from Hanami.

This first will go out in our 3.0 release candidate in a couple of weeks. I’d love your feedback to make sure this is as robust as possible!


An “i18n” component is automatically available

If you have the i18n gem bundled, an "i18n" component will automatically be registered for you:

# Gemfile
gem "i18n"

# config/i18n/en.yml
en:
  greeting: Hello, %{name}!

# No setup required!
Hanami.app["i18n"].t("greeting", name: "Alice") # => Hello, Alice!

Behind the scenes, this is done via an internally-registered :i18n provider, which loads translation files from config/i18n/ and then registers this "i18n" component to provide access to those translations.

This "i18n" component provides the full expected I18n interface (#translate, #t, #localize, #l, etc.), but via a dedicated I18n backend loaded with only the translation files from the given app or slice. This allows for full isolation of translations by default.

Basic i18n config can be done in the app class

i18n is implemented via a provider, because it needs to register a component in our container. Being a provider, this means it has its own settings, and will allow the user to configure those settings by creating their own provider file with a configure_provider(:i18n) block. This is similar to how our :db provider works.

This approach is nice and flexible, but at the same time, i18n is a pretty basic feature and I can understand it would feel like too much friction to create a whole separate file just to configure it.

So we also allow configuration of i18n via the app and slice classes. The following settings are available:

module MyApp
  class App < Hanami::App
    config.i18n.default_locale = :es
    config.i18n.available_locales = [:es, :en]
    config.i18n.load_path += ["config/custom_translations/**/*.yml"]

    # Also (and more on this later):
    # config.i18n.shared_load_path
  end
end

When the internal i18n provider runs its prepare step, it will apply the i18n configs from the corresponding app/slice, provided the user hasn’t already explicitly configured these on the provider.

This approach allows for:

  1. You to make basic i18n configuration directly in your app/slice alongside your other config, without having to create a separate provider file for i18n.
  2. Custom i18n configuration to be done once only at the app level, then thanks to our parent->child slice config inheritance, that same config will take effect in all of the app’s slices.

For completeness, if you wanted to configure i18n directly for the provider, here’s how it would look for the same settings from above:

Hanami.app.configure_provider(:i18n) do
  config.default_locale = :es
  config.available_locales = [:es, :en]
  config.load_path += ["config/custom_translations/**/*.yml"]
end

Advanced i18n setup can be done in a provider

The settings we’ve seen above are our basic i18n settings, and they apply specifically to our default i18n backend, which is I18n::Backend::Simple from the i18n gem. This backend loads its translations from files.

You may want to configure your own custom backend, to load translations in another way. To do this, you can do so by implementing your own :i18n provider. This is because changing the backend is advanced usage, and a custom backend may have dependencies on other parts of the app (like when using e.g. database-backed translations), and providers are the right tool for managing this kind of dependency.

Changing the backend can look like this, for simpler backends:

Hanami.app.configure_provider(:i18n) do
  # A backend with no heavy dependencies can be configured directly in the provider block
  config.backend = MyBackend.new
end

Or like this, for heavier backends:

Hanami.app.configure_provider(:i18n) do
  # Configure a backend with other app dependencies in a lifecycle hook
  before(:start) do
    # A database-backed backend
    gateway = slice["db.gateway"]
    config.backend = MyDatabaseBackend.new(gateway)
  end 
end

Hanami bundles English translations for #localize

Hanami bundles the necessary English translations for the i18n #localize method to work out of the box. See lib/hanami/providers/i18n/locale/en.yml.

date = Date.new(2026, 5, 11)
Hanami.app["i18n"].localize(date, format: :short)
# => "11 May"
Hanami.app["i18n"].localize(date, format: :long)
# => "11 May 2026"

i18n helpers are available in views

While you can use the "i18n" component directly where you like, in certain areas we provide a more convenient affordance, like i18n helpers in views.

We provide the following view helpers: #translate (aliased as #t), #translate! (aliased as #t!), #localize (aliased as #l).

You can call them in all the places helpers are available: templates, parts, and scopes. For example:

<%= t("messages.welcome") %>

We provide some special behaviour for i18n used via helpers.

Translation keys with a leading dot will resolve to a key relative to the template. For example, a key of ".title" inside app/templates/users/index.html.erb will use the key "users.index.title".

Relative keys inside partials will preserve the leading underscore in the resolved key. So ".title" inside app/templates/users/_profile_box.html.erb will resolve to a key of "users._profile_box.title".

If your translation key ends with _html, then it will be marked as HTML-safe and will be interpolated into your view un-escaped:

# Given this key:
#   greeting_html: "Hello, <strong>%{name}</strong>!"

# This helper call:
<%= translate("greeting_html", name: "<script>") %>

# Will give you:
"Hello, <strong>&lt;script&gt;</strong>!"

If you reference a missing translation key, then a “translation missing” span will be inserted:

<%= translate("missing.key") %>

# Will give you:
'<span class="translation_missing" title="...">missing.key</span>'

i18n helpers are available in actions

The same i18n helpers are also available in actions, and they work just like they do inside views.

module MyApp
  module Actions
    module Posts
      class Create < MyApp::Action
        def handle(request, response)
          response.flash[:notice] = t(".created_notice")
          response.redirect_to routes.path(:posts)
        end
      end
    end
  end
end

Relative key lookup is also available. In the example above, the ".created_notice" key will resolve to "posts.create.created_notice".

Slices have isolated translation by default

By default, every slice has its own distinct "i18n" component, loading translations from that slice only.

Shared translations are loaded from config/i18n/shared/

Even with isolated translations per slice, it’s likely that you may want a common set of translations that are made available everywhere.

You can put these files in config/i18n/shared/ and they will be shared across every slice by default. These files are loaded first, so each slice’s own translations can override them as required.

Shared translations are important if you’re providing your own translations for #localize (the equivalent of the bundled English translations). In a future release, we will endeavour to provide a hanami-i18n gem that provides these basic translations for most languages.

You can also configure your own shared load paths for translations via the shared_load_path setting.

module MyApp
  class App < Hanami::App
    # The default value is ["config/i18n/shared/**/*.{yml,yaml,json,rb}"]
    config.i18n.shared_load_path += ["config/i18n/custom_shared/**/*.yml"]
  end
end 

Relative paths given to shared_load_path are resolved from the app root in all circumstances.

Translations may be shared in full across slices

If you want to share a single set of translations across all slices, add "i18n" to your shared_app_component_keys:

module MyApp
  class App < Hanami::App
    config.shared_app_component_keys += ["i18n"]
  end
end

This will see translations loaded from the top-level config/i18n/ only, and that same set of translations made available to all slices.


Below are a couple of questions I posed when I shared my first work-in-progress overview of this functionality. I’m leaving them here for posterity’s sake.

Questions

Does the i18n gem really not provide isolated I18n interfaces?

Our Hanami::Providers::I18n::Backend exists because we want to provide a fully isolated I18n-compatible interface to the per-slice translations. We have to do this ourselves since the i18n gem backends themselves do not provide that standard interface. In the i18n gem, this is done by the top-level I18n module, which is global and AFAICT is not possible to “instantiate” for specific backends only).

Is this really the case? Have I missed some important part of the i18n gem?

How do we feel about config/i18n/ as the location to hold the translation files?

  • What I like about it: it’s short, it nicely echoes the name of the provider & component (and gem!), and it feels like a good counterpart e.g. to config/db/.
  • OTOH, something like config/locales/ is possibly more self-explanatory, and also matches Rails, so it may be less surprising in that way.

On balance, I’m inclined to keep config/i18n/, but I’m open to input!

3 Likes

Hi @timriley,

thank you for making this possible in Hanami. As far as I can tell, the configuration as proposed will help us manage translations more clearly. What we did in our app (funny enough, it’s a translation tool :slight_smile: ) based on this post and came up with simple config across the entire project in “our_app → config → initializers → i18n.rb” with basic settings as you specified above.

Then we have yml files in “our_app → config → locale”. The tricky part is that we it gets difficult to manage all the texts just by file names for each app and by key names within the yml files. So we have admin_en.yml and admin_sl.yml. Within the files themselves we manage texts by template names and text types.

The initial setup is not problematic, managing everything for all apps however turned out to be quite complex. Separation by slices would help with this regard for sure.

EDIT:

For reference: our app is still at Hanami 1.3.

1 Like

Thank you Tim for your work! I’ll try it this week.

About the location of the locale files, config/locales seems more natural, because we’re talking about data. i18n is a concept but also the name of the gem, but locales are what I’ll found in the folder. But I guess we’d be able to configure it anyway?

By the way I like your double approach with a simple settings, that can be overriden by a full provider :hugs:

I’ll let you know more when I’ll try it for real

Thanks folks for checking this out!

I’ve just added support for fallbacks, and also merged the PR.

I haven’t changed anything about the config/i18n/ folder naming. Have a go with using it and see how it sits with you. It is configurable, however, just change the load_path to ["config/my_cool_translations/**/*.{yml,yaml,json,rb}"] and you’ll be golden :slight_smile:

I’ll leave another update here when we’ve added extra support for this within the view layer.

It works! (the most important thing!)

I would prefer if it was in config/locales by default though as I rarely am thinking ‘look in some i18n folder’. locales just tends to fit the mental model for my brain across ruby / javascript frameworks

1 Like

Hi everyone! Our streamlined i18n support is now fully complete, and I’ve updated the top post with a much more thorough walkthrough of the functionality. New since my original post: a properly isolated #localize, default English translations for #localize, view helpers, action helpers, relative key lookups in actions and views, plus a new (configurable) location for translations shared across all slices. That’s a lot of good stuff!

Please give it another read and share your feedback! Thank you!