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:
- 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.
- 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><script></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!