navree
Documentation

Localization

Translate your app with Fluent files and t!: plurals and variables, the locale negotiated from the user, the workspace or Accept-Language, and translated validation messages, mail and notifications.

On this page

Introduction

Featurei18n navree-i18n lets one app answer a sales team in Lyon in French and one in Munich in German, from the same code. You write your text in Fluent files, one directory per locale, and look messages up by id:

Rust
  • Fluent handles what string tables can't: plurals with the rules of each language, selectors, terms and attributes, and numbers, amounts and dates formatted for the locale.
  • The locale is negotiated for each request from, in an order you choose, a query parameter, the user's saved preference, the workspace's saved locale, a cookie and Accept-Language.
  • It follows the work. Mail rendered during a request is in the request's locale, and notifications render in the recipient's.
  • Validation messages are translated, with field names, so a 400 reads "prénom ne doit pas être vide".
  • Checked in CI. navree i18n:check fails on a syntax error or a message missing from a locale.

Installation

The i18n feature of the navree facade is opt-in (it is not part of full, because it compiles in ICU4X's locale data), so turn it on by name:

Cargo.toml

It brings navree::i18n (the navree-i18n crate with t!, args! and embed_locales!) and puts I18nModule and Locale in the prelude.

Add I18nModule to your app root. Put it before ValidationModule and MailModule, whose messages it translates:

src/app.rs

The module is global. It adds a layer that negotiates each request's locale, the Locale extractor, the I18n service and the t() function in templates. It contributes the migration set i18n, which creates locale_preferences for saved user and workspace locales when the app has DatabaseModule.

Configuration

config/app.toml
KeyDefaultMeaning
i18n.default_localeenThe locale when negotiation finds nothing, and the last fallback for missing messages
i18n.locales["en"]The supported locales, as BCP 47 tags
i18n.dirlocalesWhere the Fluent files are, relative to the working directory
i18n.negotiation["user", "workspace", "header"]The sources the locale comes from, first match wins
i18n.query_paramlocaleThe query source's parameter
i18n.cookielocaleThe cookie source's cookie name
i18n.missingwarnA missing message: warn logs once and shows the id, error fails the lookup (and t! panics), id shows the id silently
i18n.reloadfalseReloads changed files without a restart, checked twice a second (development; not with embedded files)
i18n.bidi_isolationautoUnicode isolation marks around variables: auto uses them only when a locale is right-to-left, always (or true) and never (or false)
i18n.routestrueMounts GET /api/i18n/locales and PUT /api/me/locale

From the environment: APP_I18N__DEFAULT_LOCALE=fr, and lists as comma-separated strings, APP_I18N__LOCALES=en,fr,de. A locale tag that doesn't parse, a default_locale that isn't in locales, a Fluent file with a syntax error or a message defined twice fails the boot, naming the key, or the file and line.

Shipping the files in the binary

By default the files are read from i18n.dir at boot, so they must be deployed next to the binary. To build one self-contained binary, embed them at compile time instead:

src/app.rs

embed_locales! reads the directory, relative to your crate's Cargo.toml, when you build, so a syntax error in a Fluent file becomes a compile error naming the file and line. Editing a file rebuilds the crate; a new file is picked up on the next build of the crate.

Writing translations

Each locale has a directory under locales/, and each directory holds any number of .ftl files, also in subdirectories. The file names are only for you: every message of a locale shares one namespace, and a message defined twice is an error.

Translations for three locales
  • locales/
    • en/
      • main.ftl
      • deals.ftl
      • mail.ftl
      • validation.ftl
    • fr/
      • main.ftl
      • deals.ftl
      • mail.ftl
      • validation.ftl
    • pt-BR/
      • deals.ftl

A message is an id, an equals sign and its text. Variables are { $name }, and a selector picks a variant, with * marking the default one:

locales/en/deals.ftl
locales/fr/deals.ftl

Plural categories (zero, one, two, few, many, other) follow each language's rules: Polish has few and many, Japanese only other. Exact numbers like [0] match before categories.

Arguments format with the locale's conventions:

  • Numbers get the locale's separators: { $count } is 1,234 in en and 1 234 in fr. NUMBER() takes style (decimal, percent, or currency with a literal currency: "EUR"), useGrouping, minimumIntegerDigits, minimumFractionDigits and maximumFractionDigits; a year is { NUMBER($year, useGrouping: "false") }.
  • Amounts are Money arguments: t!("deal-amount", amount = Money::new(4_200_000, "EUR")) reads €42,000.00 in en and 42 000,00 € in fr. Fluent's named arguments are literals, so a currency that varies travels inside the Money value.
  • Dates are chrono values (NaiveDate, NaiveDateTime, DateTime): Oct 26, 2026 in en, 26 oct. 2026 in fr. DATETIME() takes dateStyle (short, medium, long, full) and timeStyle (short, medium), which adds the time.

A message missing from fr-CA falls back to fr, then to default_locale. Region-specific files only need the messages that differ. A message's attributes are looked up as id.attribute: t!("login.title").

Translating in code

The t! macro

t! looks a message up in the current locale. Arguments are name = value; numbers, strings, bools, Money, chrono dates and Options of those work:

src/modules/deals/service.rs

During a request, the current locale is the one negotiated for it. Elsewhere (a job, a command, a scheduled task) it is default_locale. To translate in another locale, run the code inside Locale::scope, which takes a Locale or a tag:

Rust

A tag is matched with the supported locales as a request's is, so fr-CA scopes to fr, and an unsupported one to default_locale. A validated navree::validation::Locale from a request body works as a tag too: Locale::scope(&input.locale, ...). Outside a request, Locale::current() and tags given to Locale::scope use the app that booted last in the process (I18n::scope and I18n::locale name one explicitly).

A job of your own runs in default_locale. To run it in the locale of the request that dispatched it, carry the tag in the job:

Rust

The Locale extractor

Take Locale in a handler to see which locale was negotiated, or to format with it:

Rust

Locale also has language() (pt for pt-BR), format_number, format_date_with(date, DateStyle::Long), direction() (ltr or rtl), native_name(), translate(id, &args) and has(id). Every response that passes through the layer carries Content-Language: fr.

The I18n service

I18n translates in an explicit locale, without changing the current one, and manages saved preferences:

Rust

t falls back like t!, translates in default_locale when the tag matches no supported locale, and returns I18nError::InvalidLocale for a string that is not a language tag. has looks at the locale and its parents, not at the default locale's fallback. Saving an unsupported locale is I18nError::UnsupportedLocale, and saving without DatabaseModule is I18nError::NoDatabase. I18n::new(options) builds one without an app, for tools and unit tests.

Negotiating the locale

The layer tries the sources in i18n.negotiation, in order, and keeps the first supported locale:

SourceReads
query?locale=fr, for links in mails and shared URLs
userThe signed-in user's saved locale
workspaceThe saved locale of the workspace the request names, when the signed-in user is a member
cookieThe locale cookie, which a BFF can set
headerAccept-Language, by quality: fr-CA;q=0.9, en;q=0.8

Matching is lenient where it helps: fr-CA falls back to fr when only fr is supported, and pt matches pt-BR when that is the first Portuguese locale. A source that names an unsupported locale is skipped, not an error, and nothing supported means default_locale.

Users save their locale with a built-in route, mounted when i18n.routes is on (the default) and the app has DatabaseModule and an authenticator:

HTTP

The answer is 204, and the matching supported locale is saved (fr-FR is saved as fr). An unsupported locale is a 400 with errors.locale, in the request's language. GET /api/i18n/locales lists the supported locales with their names in their own language for a language picker:

JSON

Validation messages

With I18nModule in the app, validation messages come from Fluent. Each message key from the validation message table becomes an id prefixed with validation-, with dots turned into hyphens (min.string is validation-min-string), and the placeholders become variables ($attribute, $min, $max, $other, $values, $value, $format, $pattern; numbers are numbers, so they select plural variants):

locales/fr/validation.ftl

field-<path> gives a field its display name, as validation.attributes does (a dotted path's dots become hyphens: field-address-city). A request in French then gets:

JSON

navree-i18n ships the validation messages in English, French, German, Spanish, Italian, Dutch and Brazilian Portuguese, used for every locale of those languages. They cover every key of the table, the international ones too (phone, country, locale, unicode_name). A message comes from, in order: your Fluent message for the locale or a parent of it; in the default locale's language, the validation.messages section; the shipped message for the locale's language; the validation.messages section; the default locale's message; the built-in English template. So validation.messages still overrides English when English is the default, and translated languages keep their translations. Copy the shipped messages into your locales/ to edit them:

Shell

It writes locales/<locale>/validation.ftl for each supported locale whose language is shipped, and keeps files that exist unless you add --force.

Mail and notifications

Templates get a t function and a locale() function, and the money and date filters format for the current locale (date without a format uses the locale's medium style; with a strftime format it formats as before):

views/mail/deal-won.html

A mail renders in the locale that is current when it is rendered, so translate the subject with t! where you build it:

Rust

Mailer::queue renders the mail before it dispatches the job, so queued mail is in the locale of the request that queued it. Mail to someone else, such as a deal owner who reads French while a German manager reassigns the deal, should use the recipient's locale. Notifications do that for you: navree-i18n renders each notification for a user (via, to_mail, to_database, and its mail when a queued notification is delivered) inside Locale::scope with the user's saved locale, or default_locale when the user saved none. For a mail you send yourself, wrap it:

Rust

Checking translations

i18n:check parses every file of every locale and compares each locale with default_locale:

Shell
Text

It exits with status 1 when a file has an error or a message is defined twice, or when a locale other than a regional one (pt-BR falls back to pt or the default) is missing messages, so run it in CI. It loads the files leniently, so it reports syntax errors that would stop the app from booting. i18n:missing fr lists the missing ids of one locale, one per line, ready to send to a translator.

Testing

Send Accept-Language (or ?locale=) and assert on the translated text. Set i18n.missing = "error" in tests, so a missing message fails instead of showing its id:

tests/locale.rs

Locale::scope works without a request, so unit tests of services that call t! don't need the HTTP client. Tests in one process share "the app booted last", so a test that boots apps with different locales should scope with app.get::<I18n>().await.locale("fr") instead of a tag.

Reference

Item
t!(id, name = value, ...)Translates in the current locale
args!(name = value, ...)Fluent arguments (Args) for I18n::t and Locale::translate
embed_locales!(dir)Embeds the Fluent files in the binary
Money::new(minor_units, currency)An amount argument, formatted for the locale
LocaleExtractor: tag(), language(), direction(), native_name(), translate, has, format_money, format_date, format_date_with, format_number; Locale::scope(locale, future), Locale::current()
I18nService: new, t, has, locales, default_locale, locale, matching, scope, user_locale, set_user_locale, workspace_locale, set_workspace_locale, missing, reload
I18nErrorInvalidLocale, UnsupportedLocale, MissingMessage, Format, Files, NoDatabase, Database
Route
PUT /api/me/localeSaves the signed-in user's locale
GET /api/i18n/localesThe supported locales with their native names
CommandWhat it does
i18n:checkParses every file and reports problems and missing messages; exits 1 on a problem
i18n:missing <locale>Lists the ids a locale is missing
i18n:publish validation [--force]Copies the shipped validation messages into locales/