Autumn ships an opt-in i18n module that gives Spring Boot / Rails / Phoenix migrants the localization story they expect, with convention over configuration and a small, well-defined surface.
The module is gated behind the i18n feature flag, so apps that don't
enable it pay zero compile cost and incur zero runtime cost. It is built
around three ideas:
- Translations live at
i18n/<locale>.ftl— a Project Fluent file per locale, discovered from the project root at startup. - A request-scoped
Localeextractor resolves the active locale from the request, in a stable documented order. - A
t!()macro performs the actual key lookup with automatic fallback to the default locale and a rate-limitedtracing::warn!on misses.
Status: ships in
v0.4.xas opt-in via thei18nCargo feature.
Quick start
1. Enable the feature flag
# Cargo.toml
[dependencies]
autumn-web = { version = "0.4", features = ["i18n"] }
2. Configure supported locales
# autumn.toml
[i18n]
default_locale = "en"
supported_locales = ["en", "es"]
# Optional — when omitted, falls back to [default_locale].
fallback_chain = ["en"]
# Optional — defaults to "i18n".
dir = "i18n"
3. Author a translation file per locale
# i18n/en.ftl
welcome.title = Welcome to my blog
welcome.greeting = Hello, { $name }!
# i18n/es.ftl
welcome.title = Bienvenido a mi blog
welcome.greeting = ¡Hola, { $name }!
4. Auto-load at app startup
use autumn_web::prelude::*;
#[get("/")]
async fn index(locale: Locale) -> Markup {
html! {
h1 { (t!(locale, "welcome.title")) }
p { (t!(locale, "welcome.greeting", name = "Ada")) }
}
}
#[autumn_web::main]
async fn main() {
autumn_web::app()
.i18n_auto() // discovers `i18n/` dir from the [i18n] block
.routes(routes![index])
.run()
.await;
}
That's the whole flow. Open the app at /?locale=es to see Spanish, or
set Accept-Language: es in your browser.
File convention
Every .ftl file inside the configured dir (default: i18n/) is
loaded at startup, keyed by its filename stem. So i18n/en.ftl becomes
the en locale, i18n/pt-BR.ftl becomes pt-BR, etc.
The default locale's file is mandatory: if it's missing,
Bundle::load_from_dir returns
LoadError::MissingDefaultLocale and .i18n_auto() panics with the
typed error message. This is the spec's "fail fast" rule — a
half-localized app is worse than a clearly broken one.
Files that are not the default locale are optional; missing locales just won't be served, and the negotiation step will fall through to the next-best match.
Resolution order
The Locale extractor walks the request in this order, returning the
first locale that matches the configured supported_locales:
?locale=xxquery parameter (explicit override; useful for testing and for "language switcher" links).- Signed session cookie — the
autumn_localekey inside the framework's HMAC-signed session, set viaautumn_web::i18n::set_locale_in_session(session, locale).await. This is the recommended way to persist a switcher choice. - Plain
autumn_localecookie — unsigned, set viaautumn_web::i18n::set_locale_cookie(locale). Useful when sessions are not enabled. Accept-Languagerequest header, with full RFC 7231 q-value negotiation (e.g.es-MX,es;q=0.9,en;q=0.7matchesesif only["en", "es"]are supported).- The configured
default_locale.
The order is stable. Applications can rely on it. If steps 1–4 produce a locale that is not in the supported list, the extractor falls through to the next step rather than serving an unsupported locale.
Implementing a locale switcher
The simplest switcher is a pair of ?locale= links:
html! {
a href="?locale=en" { "English" }
a href="?locale=es" { "Español" }
}
The recommended way to persist the choice across navigations is the
framework's signed session cookie via set_locale_in_session. The
session cookie is HMAC-signed by the framework, so a hostile client
cannot forge it:
use autumn_web::i18n::set_locale_in_session;
#[post("/locale/{locale}")]
async fn switch(session: Session, Path(locale): Path<String>) -> impl IntoResponse {
set_locale_in_session(&session, &locale).await;
Redirect::to("/")
}
For apps that don't use the session subsystem, the unsigned
autumn_locale cookie via set_locale_cookie(locale) is the fallback —
note it lives after the session in the resolution order, so a
session-set locale always wins.
The t!() macro
t! is a proc-macro (lives in autumn_macros, re-exported as
autumn_web::t and from autumn_web::prelude when the i18n feature is
on). Two forms:
// Without args:
t!(locale, "welcome.title")
// With named args (Project Fluent's `{ $name }` placeable syntax):
t!(locale, "welcome.greeting", name = "Ada")
Compile-time key validation
At expansion time the proc-macro reads
$CARGO_MANIFEST_DIR/i18n/<default_locale>.ftl (where
<default_locale> is the value of the AUTUMN_I18N_DEFAULT_LOCALE
env var, defaulting to "en"; AUTUMN_I18N_FILE overrides the path).
If the requested key is not present, the build fails:
error: i18n key `welcome.tite` is not defined in the default locale bundle
hint: did you mean `welcome.title`?
--> src/routes/index.rs:12:24
|
12 | t!(locale, "welcome.tite")
| ^^^^^^^^^^^^^^
If the file does not exist (e.g. a brand-new app that just enabled the
feature flag), the macro falls back to a runtime-only call — the build
succeeds and the runtime {$key} marker surfaces the missing key. As
soon as you author i18n/en.ftl, the next build picks up the
compile-time check automatically.
Runtime behaviour
- If the key is missing in the requested locale, the bundle walks the
configured
fallback_chainand returns the first hit. - If no fallback hits, it returns
{$key}so the missing key is visible at render time, and atracing::warn!(rate-limited per(locale, key)pair) is emitted. - Unknown placeables (
{ $name }with no matching arg) are left literally in the output for the same reason: silent empty strings hide bugs.
Localizing validator error messages
This is a documented pattern, not new public API. Map a
validator::ValidationErrors to a localized error map by using the
field name plus a convention like validation.<field>.<code>:
# i18n/en.ftl
validation.email.email = Please enter a valid email address.
validation.password.length = Password must be at least 8 characters.
use validator::ValidationErrors;
fn localize_errors(errors: &ValidationErrors, locale: &Locale) -> Vec<(String, String)> {
let mut out = Vec::new();
for (field, field_errors) in errors.field_errors() {
for err in field_errors {
let key = format!("validation.{field}.{}", err.code);
out.push((field.to_string(), t!(locale, &key)));
}
}
out
}
If a code is missing from the .ftl, the {$key} marker makes the
omission obvious during testing. We deliberately do not fork or replace
validator — its API is the API.
Scaffolding a new project
autumn new can scaffold the i18n module for you with a flag — it is
off by default so the baseline new-project experience stays minimal:
autumn new my-app --with-i18n
This creates i18n/en.ftl with a stub welcome.title /
welcome.greeting pair, adds the [i18n] block to autumn.toml,
enables the i18n feature on the autumn-web dependency, and wires
.i18n_auto() into main.rs. Drop additional i18n/<locale>.ftl
files at your leisure.
Migrating from monolingual
The migration is bounded by the size of your translation file, not by the number of files you have to touch:
- Add the feature flag to
Cargo.tomland the[i18n]block toautumn.toml. - Move every literal string in your templates into
i18n/en.ftlunder a stable key (we recommend<page>.<element>). This is a pure edit pass — no logic changes. - Replace each literal in your handlers / templates with
t!(locale, "key"). Any handler that needs translations adds aLocaleparameter. - Author additional locales (
i18n/es.ftl, etc.) at your leisure. Missing keys fall back todefault_localeso partial coverage works from day one. - Add a locale switcher somewhere visible.
There is no schema change, no per-handler wiring. The shape of the translation file is the only authored surface.
Out of scope
The framework deliberately does not ship:
- Translation management tooling integrations (Crowdin, Lokalise, Phrase).
Author
.ftlfiles in your editor. - ICU MessageFormat. Use Fluent's
NUMBERandDATETIMEbuilt-ins for number / date formatting. - Right-to-left layout helpers in
autumn-web/ui. The CSS / layout side stays the user's problem; the framework only owns text resolution. - A localized version of every framework-emitted error page. Track separately if demand emerges.
- A reimplementation of
validator's message system. We document the integration pattern above; we do not fork or replacevalidator. - Hot-reloading translation files in
autumn dev. Nice-to-have, not blocking.
If your app needs richer plural / gender rules, author them in Fluent itself — the format already supports them.
See also
autumn_web::i18n— module-level rustdoc with full API.examples/blog— visit/greetfor the working end-to-end demo (English + Spanish).- Project Fluent — upstream syntax reference.