Localization (i18n)
Avo names everything for you out of the box: resources and actions after their class names, fields after their ids, and the buttons and headings around them from Avo's own locale files, shipped in 19 languages through Rails' I18n module. When you want different copy, there are two ways to set it — and this page covers both.
Drive the copy from your locale files. Everything you build — resources, fields, actions, scopes, cards, dashboards, tabs, panels — resolves its copy from a predictable i18n key before anything else. One YAML file can rename the whole admin:
# config/locales/avo.es.yml
es:
avo:
resource_translations:
user:
one: "Usuario"
other: "Usuarios"
fields:
created_at: "Fecha de creación"
action_translations:
toggle_inactive:
name: "Alternar inactivo"If your app serves multiple languages, definitely work this way: every string lives in the locale files, and adding a language means adding a file, never touching a resource. It's just as useful in a single-language app for renaming things in bulk without editing code.
Or set the copy in code, with the built-in properties. self.name and self.message on an action, name:, help: and placeholder: on a field, label: on a card. Use these when you want the string next to the thing it describes, or when a label needs data — they accept lambdas with Avo's execution context:
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
self.name = "Toggle inactive"
self.message = -> { "Toggle #{record.name}?" }
endThe two compose — a single cascade decides which wins — so you can start with strings in code and move them into locale files when the second language arrives.
Naming records is different
self.title = :name on a resource picks which attribute stands in for a record — the heading on its Show page, its label in a belongs_to select. That's data, not copy, so i18n doesn't enter into it. This page is about the copy around your records.
Translations are used exactly as you write them
Avo renders a resolved resource or field translation verbatim. It only humanizes the name it generates for you when no translation is found. Write one: 'Usuario' and you get Usuario; write one: 'usuario' and you get usuario. This also means acronyms and proper nouns survive -- 'Payment Intent ID' stays Payment Intent ID instead of becoming Payment intent id.
Multi-language URL Support
If you're serving Avo using multiple languages and you're using the locale in your routes (/en/resources/users, /de/resources/users), check out this guide.
Avo ships its own locale files (avo.en.yml plus 18 other languages) bundled inside the gem and loads them into the I18n module automatically, so bin/rails avo:install doesn't copy them into your app. When you want editable copies under config/locales, run the locale generator.
How Avo resolves a label
Every label goes through the same cascade. The first layer that has a value wins:
- The value at the call site —
field :dates, name: "Period", orcard Avo::Cards::UsersMetric, label: "Active users". - The i18n key — derived per the key map below, or set explicitly with
self.translation_key/translation_key:. For a field, the model's own attribute translation (activerecord.attributes.<model>.<attr>, thenattributes.<attr>) is read right after Avo's keys — see Localizing fields. - The class attribute —
self.name,self.message,self.label,self.descriptionon actions, scopes, cards, and dashboards. - What Avo generates — the humanized class name or field id, or Avo's own translated string (
avo.run,avo.save, …).
Not every layer exists everywhere — resources have no naming attribute, and actions take nothing at the call site — but the order never changes: an explicit call-site value beats the locale file, and the locale file beats a class attribute. That last part is what lets you keep English defaults in code and translate over them without touching the class.
The key map
Every key Avo derives, in one place:
| You're naming | Avo looks up | Keys under it | Comes from |
|---|---|---|---|
| Resource | avo.resource_translations.<class_path> | zero/one/other, description, plus the fields, tabs, panels and save subtrees | Avo |
| Field | avo.resource_translations.<resource>.fields.<field_id>, then avo.field_translations.<field_id>, then activerecord.attributes.<model>.<field_id> and attributes.<field_id> | zero/one/other, help, placeholder, include_blank (the model keys take a plain string or one/other) | Avo |
| Action | avo.action_translations.<class_path> | name, message, confirm_button_label, cancel_button_label, description | Avo |
| Tab | avo.resource_translations.<resource>.tabs.<slug> | the title itself | Avo |
| Panel | avo.resource_translations.<resource>.panels.<slug> | the title itself | Avo |
| Save button | avo.resource_translations.<resource>.save, then avo.save | the label itself | Avo |
| Scope | avo.scope_translations.<class_path> | name, description | avo-scopes |
| Card | avo.card_translations.<class_path> | label, description, discreet_description | avo-dashboards |
| Dashboard | avo.dashboard_translations.<class_path> | name, description | avo-dashboards |
Filters are the one thing with no derived key yet — name them with a lambda, as shown in Where your own keys go.
How the paths are derived
<class_path>— the class name minus its DSL container prefix (Avo::Resources::,Avo::Actions::,Avo::Scopes::,Avo::Cards::,Avo::Dashboards::), underscored. A namespace becomes a slash:Avo::Resources::User→user,Avo::Actions::City::Update→city/update.<field_id>— the field's first argument:field :created_at→created_at.<slug>— the tab's or panel'stitle:, downcased with non-alphanumerics turned into_:title: "Contact information"→contact_information.<resource>— the<class_path>of the resource the field, tab, panel, or save button belongs to.
Every derived path can be replaced with your own: self.translation_key = "..." on the class, or translation_key: "..." on a field, tab, or panel.
Localizing resources
Let's say you want to localize a resource. All you need to do is add a self.translation_key class attribute in the Resource file. That will tell Avo to use that translation key to localize this resource. That will change the labels of that resource everywhere in Avo.
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
self.title = :name
self.translation_key = 'avo.resource_translations.user'
end# avo.es.yml
es:
avo:
dashboard: 'Dashboard'
# ... other translation keys
resource_translations:
user:
zero: 'Usuarios'
one: 'Usuario'
other: 'Usuarios'
description: 'Los usuarios de la aplicación'These values are used as written, so capitalize them the way you want them to appear in page titles, breadcrumbs, and the sidebar.
description fills the resource's self.description text. Like everywhere else in the cascade, the locale file beats self.description on the class, so you can leave an English default in code and translate over it. The one thing to watch: if self.description is a block that reads record or view, don't add the key for that resource — a static translation would take its place.
If you don't set self.translation_key, Avo derives it from the resource's class name, namespace included — a namespaced resource like Avo::Resources::Galaxy::Planet defaults to avo.resource_translations.galaxy/planet.
Localizing actions
Actions follow the same convention. Avo looks up avo.action_translations.<class_path>.{name,message,confirm_button_label,cancel_button_label,description} before falling back to the class attributes (self.name, self.message, and so on).
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
# Optional. Defaults to avo.action_translations.toggle_inactive
# self.translation_key = "avo.action_translations.toggle_inactive"
end# avo.es.yml
es:
avo:
action_translations:
toggle_inactive:
name: "Alternar inactivo"
message: "¿Seguro que quieres alternar el estado inactivo?"
confirm_button_label: "Alternar"
cancel_button_label: "Cancelar"
description: "Alterna el estado inactivo del usuario"
city/update:
name: "Actualizar ciudad"Namespaced actions use the same underscored, slash-joined path as resources — Avo::Actions::City::Update resolves to avo.action_translations.city/update.
You can still set self.name, self.message, and the button labels as strings or lambdas. Those remain the fallback when no translation key is present, which is useful for dynamic labels that depend on arguments or record.
Localizing scopes, cards, and dashboards
Scopes, dashboard cards, and dashboards follow the same convention, from the add-on gems that provide them — their keys and attributes are in the key map.
# avo.es.yml
es:
avo:
scope_translations:
admins:
name: "Administradores"
description: "Solo administradores"
card_translations:
users_metric:
label: "Número de usuarios"
description: "En todos los equipos"
sales/monthly:
label: "Ventas mensuales"
dashboard_translations:
dashy:
name: "Panel de control"Each accepts self.translation_key to override the derived path, and each falls back to the class attribute — which may still be a String or a lambda — when no translation is present. Namespaced classes use the same slash-joined path as resources and actions.
A card's per-registration label wins over its key
Unlike actions, a card can be given a label where it is registered:
card Avo::Cards::UsersMetric, label: "Active users"That override is more specific than a per-class translation key, so it wins — otherwise a dashboard registering the same card class twice would collapse both to one string. Pass a lambda if such an override also needs translating.
Not to be confused with the gems' own chrome
avo.scope_translations.* and avo.card_translations.* are for your scopes and cards. The strings the gems render themselves — the refresh controls, the count pill — live under avo.scopes.* and avo.cards.*, documented at Scopes and Cards. The namespaces are deliberately separate so a card named refresh cannot overwrite the gem's own refresh label. What you can safely put under avo. covers which paths are yours to fill and which are already taken.
Localizing fields
Similarly, you can even localize fields. When you don't set an explicit translation_key: option on the field declaration, Avo resolves the field label through a cascade, using the first key that has a translation:
avo.resource_translations.<resource>.fields.<field_id>— resource-scoped, so you can override a label for a single resource.avo.field_translations.<field_id>— shared across every resource that uses this field id.activerecord.attributes.<model>.<field_id>, thenattributes.<field_id>— the model's own attribute translation, the one Rails uses for form labels and error messages. Parent classes are consulted too, so an STI subclass inherits its parent's translations.- The humanized field id — the fallback when no key is translated.
Setting translation_key: explicitly bypasses the cascade and uses only that key. When that key is missing, the label is the humanized id, not the model's translation.
Most localized apps already translate their attributes, so their fields come out labeled with no Avo key at all:
# config/locales/en.yml
en:
activerecord:
attributes:
location:
street: Street namefield :street, as: :text # label is "Street name"A plain string serves the singular and plural label alike; give the key one: and other: when the plural differs. Association fields look up the association name, so a belongs_to :user field reads user, not user_id.
To keep labels exactly as they were before the model's translations were consulted, turn the lookup off in the initializer:
# config/initializers/avo.rb
config.model_attribute_translations = false# app/avo/resources/project.rb
class Avo::Resources::Project < Avo::BaseResource
self.title = :name
def fields
field :id, as: :id
# ... other fields
field :files, as: :files, translation_key: 'avo.field_translations.file'
end
end# avo.es.yml
es:
avo:
dashboard: 'Dashboard'
# ... other translation keys
field_translations:
file:
zero: 'Archivos'
one: 'Archivo'
other: 'Archivos'To override a field label for a single resource, add a resource-scoped entry. It wins over the shared field_translations key, while other resources keep using the shared one:
# avo.en.yml
en:
avo:
resource_translations:
product:
fields:
title:
one: "Product title"
other: "Product titles"
field_translations:
title:
one: "Title" # shared fallback for every other resource
other: "Titles"When no explicit help:, placeholder:, or include_blank: is set on the field, Avo also resolves those from sibling keys under the same translation_key:
# avo.es.yml
es:
avo:
field_translations:
dates:
one: "Rango de fechas"
other: "Rangos de fechas"
help: "Opcional. Período estándar: desde hace 1 semana hasta hoy."
placeholder: "Elige las fechas"
include_blank: "Ninguna"# No help/placeholder/include_blank needed in Ruby — they come from the locale file
field :dates, as: :date_timeYou can point translation_key at a resource-scoped path if you prefer to keep field copy next to the resource:
avo:
resource_translations:
import_guesty:
fields:
dates:
help: Optional. Standard period: 1 week ago to present.
placeholder: Choose dates
include_blank: Nonefield :dates,
as: :date_time,
translation_key: "avo.resource_translations.import_guesty.fields.dates"Explicit help:, placeholder:, and include_blank: options (strings or lambdas) still win over the locale file.
Localizing tabs and panels
Tab and panel titles localize through the resource's translation key too. Avo parameterizes the configured title: (downcased, non-alphanumerics turned into _) and looks it up under a tabs or panels scope:
avo.resource_translations.<resource>.tabs.<title>for atabavo.resource_translations.<resource>.panels.<title>for apanel
So tab title: "Activity" resolves to avo.resource_translations.user.tabs.activity, and panel title: "Contact information" resolves to avo.resource_translations.user.panels.contact_information:
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
def fields
tabs do
tab title: "Activity" do
field :last_seen_at, as: :date_time
end
end
panel title: "Contact information" do
field :email, as: :text
end
end
end# avo.es.yml
es:
avo:
resource_translations:
user:
tabs:
activity: "Actividad"
panels:
contact_information: "Información de contacto"The configured title: stays the fallback whenever the key has no translation. If the generated key doesn't suit you, pass an explicit translation_key: to the tab or panel and Avo uses it verbatim:
tab title: "Activity", translation_key: "avo.resource_translations.user.tabs.recent_activity" do
# ...
endLocalizing the delete confirmation
Sinceavo >= v4.3.8When a delete would also remove dependent records, the confirmation names them. Each association is named by the model's attribute translation, activerecord.attributes.<model>.<association>, the same key Rails uses for form labels. Give it one: and other: so the count reads right:
# config/locales/de.yml
de:
activerecord:
attributes:
author:
books:
one: Buch
other: BücherDeleting an author with 3 books then reads "Dadurch werden auch 3 Bücher gelöscht." Without that key, the humanized and pluralized association name is used ("3 books").
The sentence around the names comes from Avo's own locale files:
avo.destroy_also_deletes("This will also delete %{records}.")avo.checking_dependents("Checking related records..."), shown while the counts load.
Localizing buttons label
The avo.save configuration applies to all save buttons. If you wish to customize the localization for a specific resource, such as Avo::Resources::Product, you can achieve this by:
---
en:
avo:
resource_translations:
product:
save: "Save the product!"Setting the locale
Setting the locale for Avo is pretty simple. Just use the config.locale = :en config attribute. Default is nil and will fall back to whatever you have configured in as config.i18n.default_locale in application.rb.
Avo.configure do |config|
config.locale = :en # default is nil
endThat will change the locale only for Avo requests. The rest of your app will still use your locale set in application.rb.
You can also change Avo's default locale with the set_locale=pt-BR param. That will set the default locale for Avo until you restart your server.
Deprecated
set_locale logs a deprecation warning and will be removed in a future version. It changes Avo.configuration.locale for the whole process, so it switches the language for every user, not only the one who sent the request, until the server restarts. Since avo >= v4.3.1 Avo stores it only after your authenticate_with block lets the request through; earlier versions stored it before authenticating, so an unauthenticated request could switch everyone's language. To let each user pick their own language, use the locale selector. To change the language for everyone, set config.locale.
Suppose you wish to change the locale only for one request using the force_locale=pt-BR param. That will set the locale for that request and keep the force_locale param in all links while you navigate Avo. Remove that param when you want to go back to your configured default_locale.
Locale selector
Avo adds a language picker to the top navbar, next to the appearance switcher. It shows the current language code (EN, RO) and opens a list of the available languages, each written in its own language (Deutsch, Română, 日本語, العربية). Lists longer than eight languages get a search box. Picking one reloads the page in that language, and right-to-left languages flip the layout.
The choice is stored per user in a cookie named avo.locale, so it follows that browser across requests and never changes the language for anyone else.
The selector is enabled by default. Use config.locale_selector to choose which languages it lists, or to turn it off:
Avo.configure do |config|
# Every locale in I18n.available_locales that Avo has translations for (default)
config.locale_selector = true
# Exactly these locales, in this order
config.locale_selector = [:en, :ro, :de]
# No selector
config.locale_selector = false
endWith true, a locale shows up only when both conditions hold: it is in your app's I18n.available_locales, and Avo has an avo translation tree for it, either one Avo ships or one you add in config/locales. Locales that other gems register without Avo translations are left out. With an array, locales missing from I18n.available_locales are dropped. The selector hides itself when fewer than two locales remain.
Avo picks the locale for each request in this order:
- The
force_localeparam. - The
set_localeparam (deprecated, and stored inAvo.configuration.localeonly once the request is authenticated). - The language picked in the locale selector. A cookie naming a locale the selector does not list is ignored, and so is the cookie when the selector is disabled.
config.locale, thenconfig.i18n.default_locale.
A force_locale or set_locale value missing from I18n.available_locales is ignored, and Avo moves on to the next source.
Related:
- Check out our guide for multilingual records.
Right-to-left locales
Avo detects right-to-left languages from the active locale and flips its layout automatically — no configuration needed. The built-in RTL locales are ar, he, fa, ur, yi, ps, sd, ku, ckb, ug, and dv. Matching is done on the language segment, so a regional variant like ar-EG counts as RTL too.
Dates and the date picker
Dates on the Index and Show views and the month and weekday names in the date picker follow the active locale through the browser's Intl data, so there are no locale files to add for them. The picker's AM/PM labels, tooltips and aria labels stay in English.
A locale you pass through picker_options merges over the generated names instead of replacing them, so a filter that starts the week on Monday keeps the localized names:
def picker_options(value)
super.merge(locale: { firstDayOfWeek: 1 })
endCustomize the locale
If there's anything in the locale files that you would like to change, run bin/rails generate avo:locales to generate the locale files.
These provide a guide for you for when you want to add more languages.
If you do translate Avo in a new language please consider contributing it to the main repo. Thank you
What you can safely put under avo.
Avo's locale files, every add-on gem's, and your own all deep-merge into a single avo tree per locale — and your config/locales loads last. Overriding one of Avo's strings is supported; it's most of what this page is about. What silently breaks an app is changing a key's shape.
- Replace a string with a string. Set
avo.saveto whatever you like and every save button follows. - Never nest a key beneath a path Avo holds as a string. A String and a Hash can't merge, so the file that loads last replaces the other one outright. There's no error and no warning — the label simply stops rendering.
Plenty of Avo's strings read like namespaces. avo.actions, avo.resources, avo.dashboards, avo.dashboard, avo.filters, avo.pages and avo.tools are all labels Avo renders, and so are one-word keys like avo.all, avo.new, avo.edit, avo.delete, avo.copy, avo.confirm, avo.reset, avo.run, avo.view, avo.save and avo.close. Treat every one of them as taken.
Avo keeps eight namespaces of its own as well — avo.appearance, avo.checkbox_list, avo.global_search, avo.key_value_field, avo.media_library, avo.nested, avo.order and avo.search. They nest a level or two deeper, and the rule holds at every level: override a string, never nest beneath one.
Pluralization hashes accept extra keys
Some of the hashes Avo ships are pluralization hashes rather than namespaces. Four sit directly under avo. — avo.file, avo.record, avo.number_of_items and avo.x_items_more — and others are nested, like avo.checkbox_list.hidden_selections. Adding a sibling next to one: and other: in any of them is safe — pluralization reads only the plural keys (zero, one, two, few, many, other) and ignores everything else.
You can tell one apart by its keys, not its depth: a hash whose keys are plural categories is a pluralization hash wherever it appears.
That's why avo.resource_translations.user carries one:/other: and fields:, tabs:, panels: and save: at the same time. It is a plural leaf and a subtree root at once, by design.
The reverse needs a little care. Avo resolves resource and field names with I18n.t(key, count: 1, default: ...), and a subtree holding no plural key at all raises I18n::InvalidPluralizationData — passing a default: doesn't prevent it. Avo rescues that and falls back to the humanized name, so a resource key holding only save: works fine (see Localizing buttons label); you just don't get a translated resource name out of it. Keep one:/other: next to the nested keys when you want both. If your own code reads one of these keys with a count:, add the plural keys or rescue the exception.
Where your own keys go
Six roots exist for keys you write — the *_translations roots in the key map. Avo derives those paths itself and ships nothing under them, so nothing of Avo's can collide with what you put there.
avo.resource_translations.<resource> nests one level further — .fields.<field_id>, .tabs.<slug>, .panels.<slug> and .save — and it's the one place Avo expects a subtree of your own.
Filters have no derived root today. Localize a filter by calling I18n.t from its self.name block with a key you choose; a namespace of your own keeps it clear of everything above.
# app/avo/filters/name.rb
class Avo::Filters::Name < Avo::Filters::TextFilter
self.name = -> { I18n.t("my_app.filters.name") }
self.button_label = -> { I18n.t("my_app.filters.name_button") }
def apply(request, query, value)
query.where("LOWER(name) LIKE ?", "%#{value}%")
end
endSee which keys are taken
I18n.t("avo") returns the merged tree — Avo's keys, every gem's, and your own overrides. List the ones that are strings and you have the set you must not nest under:
bin/rails runner 'puts I18n.t("avo").select { |_, v| v.is_a?(String) }.keys.sort'The locale generator shows you the same thing, but it isn't the tool for a look: it copies Avo's locale files into config/locales, where they load last and pin today's wording into your app, so later changes to Avo's own strings never reach you. Run it when you actually want editable copies.
Pagination strings are Pagy's own. Pagy ships a dictionary for each language and reads it through its own loader, so they sit outside the avo.* tree and none of this applies to them.
What a collision looks like
avo.dashboards is the sidebar's "Dashboards" heading — a string. Nesting a dashboard's name under it takes the heading away:
# config/locales/avo.en.yml
en:
avo:
dashboards: # Avo holds this as a string
sales:
name: "Sales"Your file loads after Avo's, avo.dashboards becomes a Hash, and the heading is gone — with nothing in the logs to say so. Use the derived root instead:
# config/locales/avo.en.yml
en:
avo:
dashboard_translations:
sales:
name: "Sales"That's exactly why avo-dashboards derives avo.dashboard_translations.* rather than reusing avo.dashboards.
Add-on gems
The locale generator covers Avo core. Add-on gems keep their own strings under their own namespace inside avo.*. Most of them ship English only — you supply the other locales in your app, the same way you would for any other key. A gem that ships more says so on its own Localization page: avo-api ships its tree in every locale core does, so the REST API's panel is translated with no work from you.
| Gem | Namespace | Reference |
|---|---|---|
| Advanced Search | avo.global_search.* | |
| Collaboration | avo.collaboration.* | |
| Dashboards & Cards | avo.cards.* | Cards · Dashboards |
| Dynamic Filters | avo.dynamic_filters.* | Localization |
| Forms & Pages | avo.forms.* | |
| Intelligence | avo.intelligence.* | |
| REST API | avo.api.token.* | Localization |
| Scopes | avo.scopes.* | Localization |
The keys deep-merge into the same avo tree as core's, so one es.yml per gem — or a single file carrying all of them — works fine.
Advanced Search is the one gem that ships no locale file at all, and it doesn't need one: every key it renders lives under avo.global_search.* or is avo.all, and Avo core translates all of them in each of its bundled locales.
Four of those keys — avo.global_search.direct_match, avo.global_search.search_results, avo.global_search.searching_on and avo.all — reached core's locale files only after 4.1.6. On 4.1.6 and earlier they fall back to English, so define them in your own locale file until you're on a newer Avo.
Deep-merging can overwrite a core key
Because the merge is into core's own avo tree, nesting keys under a path where core already holds a string replaces that string with a Hash. The live example is avo.dashboards, which core uses for the sidebar's "Dashboards" heading — so avo.dashboards.my_dashboard.name breaks that heading. Add keys under a path core does not already use — see What you can safely put under avo. for the rule and the roots reserved for your own keys.
FAQ
If you try to localize your resources and fields and it doesn't seem to work, please be aware of the following.
The I18n.t method defaults to the name of that field/resource
Internally the localization works like so I18n.t(translation_key, count: 1, default: default) where the default is the computed field/resource name. So check the structure of your translation keys.
The default is the only value Avo humanizes. When your key resolves, the translation is used exactly as written.
# config/locales/avo.pt-BR.yml
pt-BR:
avo:
field_translations:
file:
zero: 'Arquivos'
one: 'Arquivo'
other: 'Arquivos'
resource_translations:
user:
zero: 'Usuários'
one: 'Usuário'
other: 'Usuários'Using a Route Scope for Localization
To implement a route scope for localization within Avo, refer to this guide. It provides step-by-step instructions on configuring your routes to include a locale scope, enabling seamless localization handling across your application.