Manual contentsDigging DeeperBrowse 103 chapters
Manual 32 min read

Localization

Localization in Suprnova is one module with four faces: message catalogs on the server, validation errors that arrive already translated, the same catalog bytes handed to the browser, and locale-aware number, date, and list formatting. The message format is Fluent - Mozilla's .ftl, the one Firefox ships - and the whole subsystem is on by default behind the localization feature.

The shortest possible tour. Write a catalog:

# lang/en/app.ftl
welcome = Welcome to { $app }!
# lang/es/app.ftl
welcome = ¡Bienvenido a { $app }!

Use it from a handler:

use suprnova::{__, handler, HttpResponse, Request, Response};

#[handler]
pub async fn greet(_req: Request) -> Response {
    Ok(HttpResponse::text(__!("welcome", app: "Suprnova")))
}

A request with Accept-Language: es gets the Spanish string, because LocaleMiddleware resolved the locale before your handler ran. Nothing else in the handler changes - no locale parameter threaded through, no &Translator in the signature.

Why localization

Three reasons this is a framework concern rather than a crate you pick:

  • Validation messages are the framework's strings, not yours. "The email field is required." is emitted deep inside Rule::passes, far from any code you own. Unless the framework carries a translation seam, a Spanish app ships English validation errors - or you wrap every rule by hand. Suprnova's built-in rules return keyed messages; you translate them by dropping a .ftl file in, and never touch the rules.
  • The browser needs the same strings. An Inertia app renders half its text in Rust and half in Svelte/React/Vue. Two translation systems means two file formats, two review workflows, and two chances for the same sentence to drift. Suprnova serves the exact catalog the server resolved from /_suprnova/lang/<locale>.ftl, and the starter kits parse it with @fluent/bundle - one set of files, one source of truth.
  • Plurals and formats are CLDR data, not string concatenation. English has two plural categories, Russian and Polish four, Arabic six. A number is 1,234.56 in en-US and 1.234,56 in de-DE. Fluent selects on CLDR plural categories and ICU4X does the formatting, so neither is something you hand-roll per locale.

Turning the feature off (--no-default-features) is supported: the localization module doesn't compile, and validation renders its embedded English fallback strings. Nothing else changes shape.

File layout

Catalogs live under lang/, one directory per locale:

myapp/
├── lang/
│   ├── en/
│   │   ├── app.ftl
│   │   └── validation.ftl
│   └── es/
│       ├── app.ftl
│       └── validation.ftl
├── src/
└── frontend/

The rules:

  • A directory name is a BCP-47 locale - en, en-GB, pt-BR, zh-Hans. A directory whose name doesn't parse is skipped with a warn! rather than failing boot.
  • Every .ftl in a locale directory merges into one catalog, in sorted filename order. Split by feature (auth.ftl, billing.ftl, emails.ftl) as much as you like - message ids are global within the locale, so auth.ftl and billing.ftl must not define the same id.
  • The framework's own English validation catalog loads first, into every locale's bundle. Your files load over it, and a later definition wins. That is the whole override mechanism: define validation-min in lang/es/validation.ftl and the Spanish bundle uses yours.
  • The root is lang_path() - <APP_BASE_PATH>/lang. Set APP_BASE_PATH when the binary runs from somewhere other than the project root (a systemd unit, a container with a different WorkingDirectory), or call use_lang_path("…") to move only the lang directory. See Environment Variables.
  • A missing lang/ directory is not an error. A fresh app must boot, so the translator comes up with the embedded English catalog and nothing else. A malformed .ftl is a different story: parse errors fail boot, naming the file and what the parser objected to, because a silently half-loaded catalog is worse than a stopped process.
  • In local and development, catalogs hot-reload. Each request stats lang/ and reparses only when something actually changed, so editing a .ftl shows up on the next refresh. Production never re-stats; catalogs are read once at boot.

FTL in five minutes

Fluent is a small format. This section is everything you need for a typical app.

Messages are id = value pairs. Ids are kebab-case by convention (the framework's own are), values run to end of line, and indented continuation lines are joined:

# A comment. Attached to the message below it.
sign-in = Sign in
password-hint =
    Use at least 12 characters. A passphrase of a few
    ordinary words beats a short string of symbols.

Arguments are { $name } placeables. You supply them at call time; missing arguments are an error, not an empty string (Lang::get then falls through its chain - see The Lang facade):

greeting = Hello, { $name }!
invoice-line = { $qty } × { $item }

Terms start with -, are private to the catalog, and exist so a brand name or a repeated phrase lives in one place:

-product-name = Suprnova
about = About { -product-name }
footer = © 2026 { -product-name }. All rights reserved.

Selectors are Fluent's conditional. The selector value is matched against variant keys; exactly one variant is marked default with *:

cart-summary =
    { $count ->
        [0] Your cart is empty.
        [one] One item in your cart.
       *[other] { $count } items in your cart.
    }

[0] matches the literal number zero. [one] and [other] are CLDR plural categories, resolved for the bundle's locale - which is where Fluent earns its place. English has two categories; Russian has four, and a Russian translator writes all four without you changing a line of Rust:

# lang/ru/app.ftl
unread-messages =
    { $count ->
        [one] У вас { $count } непрочитанное сообщение.
        [few] У вас { $count } непрочитанных сообщения.
        [many] У вас { $count } непрочитанных сообщений.
       *[other] У вас { $count } непрочитанного сообщения.
    }

CLDR assigns 1, 21, 31 to one; 24, 2224 to few; 0, 520, 2530 to many; and fractions to other. The same __!("unread-messages", count: 22) call renders correctly in English, Russian, Polish, and Arabic, because the category selection is data, not code.

Always put the * on other. It is the one category CLDR defines for every locale, so it is the only variant guaranteed to exist - and the default is what an unmatched selector value falls through to, including any non-integer count. Marking *[many] (or any other category) as the default sends fractions to text written for whole numbers.

Pass counts as numbers. __!("unread-messages", count: 3) sends a JSON number and selects a plural category. count: "3" sends a string, which can only match a literal variant key - it will land on your *[other] default. This is the one FTL trap worth memorising.

Functions are called inside placeables. Two are registered: NUMBER() (Fluent's builtin) and DATETIME() (Suprnova's):

score = Your score is { NUMBER($points) } out of { NUMBER($total) }.
published = Published { DATETIME($when, dateStyle: "medium") }

See Locale-aware formatting for both.

One deliberate limitation: Suprnova resolves flat message values only. Fluent's attribute syntax (login .placeholder = …) parses but is not addressable through Lang::get, so keep one id per string: login-placeholder, not login.placeholder. Ids are a flat namespace per locale - prefix them (auth-login-title, billing-invoice-due) rather than reaching for a hierarchy the resolver doesn't have.

The Lang facade

Lang is the server-side entry point. Every method reads the current locale, which the middleware bound for this request.

Method Returns Notes
Lang::get(key) String Infallible. Runs the fallback chain, then returns the key itself
Lang::get_with(key, args) String Same, with arguments
Lang::try_get(key) Result<String, FrameworkError> Errors instead of degrading
Lang::try_get_with(key, args) Result<String, FrameworkError> Same, with arguments
Lang::has(key) bool Whether the key resolves for the current locale, or anywhere along its fallback chain
Lang::locale() Locale The current locale
Lang::set_locale(locale) () Change it for the rest of this request
Lang::available_locales() Vec<Locale> Every locale with a loaded catalog
use suprnova::{Lang, Locale, TranslateArgs};

let subject = Lang::get("password-reset-subject");

let mut args = TranslateArgs::new();
args.insert("name".into(), serde_json::json!("Ada"));
args.insert("count".into(), serde_json::json!(3));
let body = Lang::get_with("unread-messages", args);

if Lang::has("beta-banner") {
    // Only some locales ship the banner copy.
}

let locales: Vec<String> = Lang::available_locales()
    .iter()
    .map(Locale::as_str)
    .collect();

TranslateArgs is an ordered map of String to serde_json::Value, both re-exported from the crate root. Fluent arguments are strings and numbers; other JSON shapes are stringified.

The fallback chain

Lang::get never fails, and it never returns an empty string. In order:

  1. The current locale's catalog.
  2. Its configured fallback parents (see Fallback chains), walked transitively, if any are configured - pt-PT before pt-BR before whatever pt-BR itself names as a parent, and so on.
  3. The fallback locale's catalog (APP_FALLBACK_LOCALE, default en), unless it already appeared earlier in this chain.
  4. The key itself, plus one tracing::warn! per missing (locale, key) pair - once, not once per request, so a missing key in a hot path doesn't drown your logs.

Step 4 is why a missing translation renders checkout-submit in the button instead of a blank button: a visibly wrong string is a bug report waiting to happen, while an empty one is a mystery.

When you'd rather know than degrade, use the try_* siblings. They run steps 1 through 3 and return Err instead of doing step 4:

use suprnova::Lang;

// A missing key here means a broken email - fail the job, don't send
// a message with a raw key in the subject line.
let subject = Lang::try_get("invoice-paid-subject")?;

The __! macro

__! is the Laravel-muscle-memory shorthand. With no arguments it calls Lang::get; with named arguments it builds a TranslateArgs and calls Lang::get_with:

use suprnova::__;

let plain = __!("welcome-back");
let greeted = __!("greeting", name: "Ada");
let counted = __!("unread-messages", name: "Ada", count: 3);

Argument values are anything that converts into a serde_json::Value - &str, String, integers, floats, bool. The macro is exported at the crate root, so suprnova::__!("welcome-back") works without the import when you'd rather not bring __ into scope.

Fallback chains

APP_FALLBACK_LOCALE is one global net under every locale. Sometimes that's not enough: European Portuguese and Brazilian Portuguese share nearly everything and diverge on a handful of words (ficheiro/arquivo, utilizador/usuário, tu/você), and maintaining two complete catalogs means every new string has to be written twice. A fallback parent lets pt-PT inherit from pt-BR before pt-BR falls further back to the global fallback_locale - so lang/pt-PT/ only has to hold the strings that are actually different.

Configuring parents

One environment variable, comma-separated child=parent pairs:

APP_LOCALE_PARENTS=pt-PT=pt-BR

Or the builder, one call per pair, chainable:

use suprnova::{Config, Locale, LocalizationConfig};

pub fn register_all() {
    let localization = LocalizationConfig::from_env()
        .expect("APP_LOCALE / APP_FALLBACK_LOCALE must be valid BCP-47")
        .parent(
            Locale::parse("pt-PT").expect("valid locale"),
            Locale::parse("pt-BR").expect("valid locale"),
        );

    Config::register(localization);
}

Both paths feed the same map (LocalizationConfig::parents), and both are validated at boot, not at request time:

  • A pair with no =, or an empty child or parent, is a malformed APP_LOCALE_PARENTS entry - boot fails naming the bad segment.
  • A locale invalid as BCP-47 on either side of the pair fails the same way.
  • Naming the same child twice is ambiguous config, not last-wins - boot fails naming the duplicate child.
  • A cycle fails boot. The error spells out the cycle: two locales naming each other (pt-PT=pt-BR,pt-BR=pt-PT) produces `pt-PT` -> `pt-BR` -> `pt-PT`. A locale naming itself as its own parent (pt-PT=pt-PT) is the same case in miniature - `pt-PT` -> `pt-PT`. (Two code paths raise this error: parsing APP_LOCALE_PARENTS - so any app whose config goes through LocalizationConfig::from_env() fails at config load - and FluentTranslator's catalog load, which catches a cyclic map built programmatically with .parent(...). Only an app that builds its config entirely by hand and binds its own custom Translator in bootstrap_fn skips both; Lang's walk is guarded independently and still terminates safely there, it just won't get the loud boot-time error.)

The builder's .parent(child, parent) is last-write-wins for a repeated child - a later call overriding an earlier one is just a later override, not the ambiguous-input case APP_LOCALE_PARENTS guards against.

Resolution order

A chain can be more than one hop long: pt-PT names pt-BR as its parent, and pt-BR can in turn name a parent of its own. Lang::get / try_get / get_with / try_get_with / has all walk the whole thing, current locale first:

  1. The current locale's catalog.
  2. Its configured parent, then that locale's configured parent, transitively, until a locale with no configured parent is reached.
  3. The global fallback_locale (APP_FALLBACK_LOCALE), unless it already appeared earlier in the chain - including the common case where it's just the current locale itself (the en/en default).

Lang::get / Lang::get_with fall through to the key itself if nothing in the chain resolves it, exactly as The fallback chain describes; Lang::try_get / Lang::try_get_with return Err, and Lang::has returns false. This walk runs inside the Lang facade itself, so it works for any Translator - the bundled FluentTranslator, or a driver you write.

A runnable example

myapp/
├── lang/
│   ├── pt-BR/
│   │   ├── app.ftl
│   │   └── validation.ftl
│   └── pt-PT/
│       └── app.ftl
├── src/
└── frontend/
# lang/pt-BR/app.ftl
welcome = Bem-vindo ao { $app }!
file-label = Arquivo
# lang/pt-PT/app.ftl
file-label = Ficheiro
use suprnova::__;

// A request that resolved to `pt-PT`.
assert_eq!(__!("file-label"), "Ficheiro");                    // pt-PT's own override
assert_eq!(
    __!("welcome", app: "Suprnova"),
    "Bem-vindo ao Suprnova!"                                  // inherited from pt-BR
);

lang/pt-PT/ never defines welcome - it doesn't need to. file-label is a genuine one-word difference between the two catalogs, so it's the only id that gets a file.

Served catalogs are flattened

The /_suprnova/lang/pt-PT.ftl endpoint (see The catalog endpoint) never asks the browser to know that pt-BR exists. FluentTranslator pre-merges the whole chain into one resource per locale at load time - the embedded framework catalog at the bottom for en/en-* locales, then the configured parent chain, then the locale's own files - and serves that, already flattened. Fetch pt-PT.ftl and the response carries welcome and file-label both, in one request, with no client-side chain logic. ?v=<hash> still names one immutable resource; the hash simply now covers strings pulled in from pt-BR too.

Flattening covers configured parents only - it never reaches past them to fallback_locale. pt-PT's served catalog includes pt-BR's strings because pt-BR is a configured parent; it does not include en's strings just because en happens to be the global fallback. LocaleShare's fallback field always names the terminal fallback_locale, unaffected by any of this - it tells the frontend where Lang's facade-level walk would eventually land, not what's already in the file it just fetched.

Delta-file merge rules

A child catalog merges over its parent at the Fluent AST level, not by textual concatenation and not by whole-message shadowing. The override unit is the pattern, so:

  • A child value replaces the parent's value, in the parent's position in the file.
  • A child entry with attributes but no value keeps the parent's value. Retranslating .placeholder doesn't require repeating the message's own text.
  • Attributes merge by name. A same-named child attribute replaces the parent's, in place; a child-only attribute appends after the parent's own. Attributes the child doesn't mention survive from the parent - overriding a message's value never silently drops its .placeholder or .aria-label.
  • Select expressions replace whole, never variant-by-variant. A selector's variants are keyed to one locale's CLDR plural categories; because those categories are locale-dependent, splicing one variant from the parent and another from the child could produce a selector with no single locale's grammar behind it. A child that overrides a selector at all must supply every variant it wants.
  • Comments on an overridden entry stay the parent's. The comment documents the id, and the override unit is the pattern, not the comment.
  • Child-only entries append at the end, in the child's own order, comments included - an id pt-BR never defined is not an "override" of anything.

Terms (-brand) follow the identical rule, with one narrowing: a term's value is never optional in Fluent syntax, so the "attributes-but-no-value keeps the parent's value" case above applies to messages only - a child term always supplies a value, and that value always wins. Attribute merge-by-name, whole-pattern replacement for the value, and parent-wins comments all apply to terms exactly as to messages. Terms are tracked in their own namespace - overriding -brand can never shadow a message also named brand.

Why Suprnova diverges

Laravel 13 has exactly one fallback: the single global fallback_locale config value, consulted when the current locale's array is missing a key. There is no concept of one locale inheriting from a sibling locale - pt_PT.php and pt_BR.php are two unrelated arrays, and a pt_PT app either duplicates everything pt_BR already has translated, or ships without it.

Suprnova's parent chains are the Rust-side extension: an intermediate step between "this locale" and "the global fallback," configured per-locale rather than once globally. The tradeoff we didn't want to make is pushing that complexity onto the browser - a chain-aware frontend would need to fetch pt-PT.ftl, discover it's incomplete, fetch pt-BR.ftl too, and merge them client-side in JavaScript, using rules that would have to exactly match the server's. Flattening at load time instead means the served catalog is always one complete, self-contained file - the same contract the frontend already had before parent chains existed, so @fluent/bundle and the kit wrappers needed zero changes to support this feature.

Locale detection

LocaleMiddleware resolves one locale per request and binds it for the duration of the handler. The chain is config-driven and first hit wins:

  1. Session - the locale key in the session, if session middleware ran and the value names an available locale. This is where "user picked Español in settings" lives.
  2. Cookie - the locale cookie. Survives logout, so a language choice made before signing in isn't lost.
  3. Accept-Language - negotiated against available_locales() with fluent-langneg, honouring q-values. fr-CH, es;q=0.8, en;q=0.5 against catalogs en + es resolves to es.
  4. APP_LOCALE - the configured default, when nothing above hit.

A candidate that doesn't parse, or names a locale with no catalog, is skipped, not rejected. A user with a stale locale=zz cookie sees the default language, not a 500. A garbage Accept-Language header does the same. Attacker-controlled input reaches this chain on every request; it must never be able to do more than pick a language.

Wire it up in bootstrap.rs, after the session middleware, since step 1 reads the session:

use std::sync::Arc;
use suprnova::{
    global_middleware, App, LocaleMiddleware, LocaleShare, SessionConfig, SessionMiddleware,
};

pub async fn register() {
    global_middleware!(SessionMiddleware::install(SessionConfig::from_env()).await);

    // Resolves the locale and binds it for the request.
    global_middleware!(LocaleMiddleware::from_env().expect("locale config"));

    // Hands the frontend its locale + catalog URL on every Inertia page.
    App::register_inertia_shared(Arc::new(LocaleShare));
}

LocaleMiddleware::from_env() reads LocalizationConfig::from_env(); LocaleMiddleware::new(config) takes one you built yourself. A scaffolded app has both lines already.

Changing the locale mid-request

Lang::set_locale is Laravel's App::setLocale - it rewrites the current request's locale from that point on:

use suprnova::session::session_mut;
use suprnova::{FrameworkError, Lang, Locale};

/// The user just switched languages in a settings form.
pub fn switch_language(choice: &str) -> Result<(), FrameworkError> {
    let locale = Locale::parse(choice)?;
    Lang::set_locale(locale);                       // this request
    session_mut(|s| s.put("locale", choice));       // every request after
    Ok(())
}

Note the two halves: set_locale affects this request (so the redirect's flash message is already in Spanish), and the session write is what the detection chain reads on the next one.

Outside a request

Console commands, queue workers, and scheduled tasks have no request and no middleware. There, Lang::set_locale writes a process-global override that Lang::locale() consults before falling back to APP_LOCALE:

use suprnova::{command, FrameworkError, Lang, Locale, Mail};

use crate::mail::Digest;
use crate::models::user::User;

#[command(name = "mail:digest", description = "Send the weekly digest")]
pub async fn send_digest(_args: Vec<String>) -> Result<(), FrameworkError> {
    for user in User::query().get().await? {
        // Each user's stored preference, for the duration of their email.
        Lang::set_locale(Locale::parse(&user.locale)?);
        Mail::to(&user.email).send(Digest::for_user(&user)).await?;
    }
    Ok(())
}

Because that override is process-wide rather than task-local, set it at the top of each unit of work as above - don't rely on it being unchanged across an .await that another task could interleave with.

Configuration

Three environment variables. APP_LOCALE and APP_FALLBACK_LOCALE both default to en; APP_LOCALE_PARENTS defaults to empty - no per-locale overrides, only fallback_locale applies:

APP_LOCALE=en
APP_FALLBACK_LOCALE=en
# APP_LOCALE_PARENTS=pt-PT=pt-BR

Everything else is code, on LocalizationConfig. It registers like every other typed config - in your config::register_all, which runs before boot:

// src/config/mod.rs
use suprnova::{Config, Detect, Locale, LocalizationConfig};

pub fn register_all() {
    let localization = LocalizationConfig::from_env()
        .expect("APP_LOCALE / APP_FALLBACK_LOCALE must be valid BCP-47")
        .default_locale(Locale::parse("es").expect("valid locale"))
        .use_isolating(true)                                // see the divergence note
        .detection(vec![Detect::Session, Detect::Header])   // ignore the cookie
        .session_key("preferred_locale")
        .cookie_name("lang")
        .parent(                                            // see Fallback chains
            Locale::parse("pt-PT").expect("valid locale"),
            Locale::parse("pt-BR").expect("valid locale"),
        );

    Config::register(localization);
}
  • default_locale / fallback_locale - override APP_LOCALE and APP_FALLBACK_LOCALE from code. A malformed value in either place fails boot rather than silently becoming en.
  • use_isolating - Unicode isolation marks around interpolations. Off by default; turn it on when you ship an RTL locale.
  • detection - the chain, in order. Dropping Detect::Cookie means a language choice only lives in the session; dropping Detect::Header means the browser's preference is ignored entirely.
  • session_key / cookie_name - rename the two lookups.
  • parents - per-locale fallback parents (child -> parent), walked before fallback_locale when a key is missing from the child's catalog; same shape as APP_LOCALE_PARENTS. Add one with .parent(child, parent) - chainable, last write wins for a repeated child. See Fallback chains for the full contract (boot-time validation, resolution order, served-catalog flattening).

Boot binds an Arc<dyn Translator> in the container. If your app has already bound one, the framework leaves it alone - which is how you substitute a translator of your own without forking anything:

// src/bootstrap.rs
use std::sync::Arc;
use suprnova::{App, FluentTranslator, LocalizationConfig, Translator};

pub async fn register() {
    let config = LocalizationConfig::from_env().expect("locale config");
    let translator =
        FluentTranslator::from_dir("./catalogs", &config).expect("load catalogs");
    App::bind::<dyn Translator>(Arc::new(translator));
}

Translator is the extension seam: translate, has, available_locales, catalog, reload. One driver ships (FluentTranslator), and a new backend is a new driver - not a fork of the surface.

Translated validation messages

Every built-in rule returns a keyed message: a catalog key, the arguments the message needs, and an English fallback. Translation happens once, at the serialization boundary - ValidationErrors::to_json and the Inertia error bag - never inside the rule. Rules stay pure, and the whole subsystem compiles out.

The keys follow one convention:

Shape Example Used for
validation-<rule> validation-min, validation-required-if One per built-in rule, kebab-cased
field-<name> field-email A human name for a field
validation-invalid-data - The top-level "The given data was invalid." banner

To translate them, define the ids you care about in any .ftl file under the target locale:

# lang/es/validation.ftl
validation-invalid-data = Los datos proporcionados no son válidos.
validation-required = El campo { $field } es obligatorio.
validation-email = El campo { $field } debe ser una dirección de correo válida.
validation-min = El campo { $field } debe tener al menos { $min } caracteres.
validation-confirmed = La confirmación del campo { $field } no coincide.

$field is always available. Every rule's own parameters are passed under the names they carry in the framework's English catalog - $min, $max, $other, $value - and framework/src/localization/catalogs/en/validation.ftl is the canonical list of ids and arguments. Copy the ids you need out of it; you never have to override all of them.

Overriding works per locale and per key. Defining validation-min in lang/en/validation.ftl replaces the framework's English wording for that one rule and leaves the rest alone.

Field names

Interpolating a raw column name produces "The email_address field is required." The field-<name> convention fixes that:

# lang/en/validation.ftl
field-email_address = email address
field-dob = date of birth

Before rendering, the translator looks up field-<name> for the current locale. A hit is passed as $field; a miss falls back to the field name with underscores turned into spaces. So the file above is only needed for the names that humanize badly.

Custom rules

Rule::passes returns Result<(), ValidationMessage>. A keyed message participates in translation:

use suprnova::{Rule, ValidationMessage};

pub struct StartsWith(pub &'static str);

impl Rule for StartsWith {
    fn passes(&self, value: &str) -> Result<(), ValidationMessage> {
        if value.starts_with(self.0) {
            Ok(())
        } else {
            Err(ValidationMessage::keyed("validation-starts-with")
                .arg("prefix", self.0)
                .fallback(format!("must start with {}", self.0)))
        }
    }
}
# lang/en/validation.ftl
validation-starts-with = The { $field } field must start with { $prefix }.

A plain string still works, and is the right answer for a message that will only ever exist in one language:

Err("must start with acct_".into())   // keyless: rendered verbatim

Keyless messages skip translation entirely, which is what keeps existing custom rules compiling and behaving exactly as before.

The derive flow

#[derive(Validate)] errors are keyed too. The validator crate's error code becomes validation-<code> with underscores turned into dashes, and every param the validator attaches becomes a message argument - with two reserved exceptions, value and other, which are always dropped. Both carry a field's actual value rather than metadata about the rule: value is the echoed input under test, and other (set by must_match, the canonical password-confirmation rule) is the sibling field's value. Neither is ever handed to the catalog, so no .ftl override - however it phrases validation-must-match - can interpolate a submitted secret into a 422 response body. So a #[validate(email)] failure resolves validation-email like the hand-written rule does, and a locale that translates one translates both.

The frontend

The browser gets the same bytes the server resolved. Nothing is re-translated, re-exported, or kept in sync by hand.

The catalog endpoint

GET /_suprnova/lang/es.ftl              → 200 text/plain, ETag: "<hash>"
GET /_suprnova/lang/es.ftl?v=<hash>     → 200 + Cache-Control: public,
                                          max-age=31536000, immutable
GET /_suprnova/lang/es.ftl              → 304 when If-None-Match matches
GET /_suprnova/lang/zz.ftl              → 404 (no such catalog)

The body is the merged catalog for that locale - framework messages first, then its configured fallback parent chain if any (see Fallback chains), then your files in load order. ETag is the content hash. Ask for a specific hash with ?v= and the response is immutable-cacheable forever, because that URL can only ever mean one thing; ask without it and you get revalidation instead. Like /_suprnova/health, the path is exempt from the middleware chain: it must answer before a locale has been resolved, and it carries no user data.

The shared prop

LocaleShare is an InertiaSharedData the framework ships. Registered in bootstrap.rs (see Locale detection), it adds one prop to every Inertia page:

{
  "lang": {
    "locale": "es",
    "fallback": "en",
    "catalog": {
      "url": "/_suprnova/lang/es.ftl?v=9f2c1ae4",
      "hash": "9f2c1ae4"
    }
  }
}

catalog is null when no translator is bound - the share never fails a page render.

The kit wrappers

Each starter kit ships a ~100-line wrapper that reads that prop, fetches the catalog once, builds a @fluent/bundle bundle, and exposes t(). Call initLang once in your Inertia entry point (scaffolded apps already do):

// frontend/src/main.ts
import { createInertiaApp } from '@inertiajs/svelte'
import { mount } from 'svelte'
import { initLang } from './lib/lang.svelte'

createInertiaApp({
  resolve: (name) => { /* … unchanged … */ },
  async setup({ el, App, props }) {
    await initLang(props.initialPage)
    mount(App, { target: el!, props })
  },
})

Then, in components:

<!-- Svelte 5 -->
<script lang="ts">
  import { t, currentLocale } from '../lib/lang.svelte'
</script>

<h1>{t('welcome', { app: 'Suprnova' })}</h1>
<p>{currentLocale()}</p>
// React 19
import { useLang } from '../lib/lang'

export default function Home() {
  const { t, locale } = useLang()
  return <h1>{t('welcome', { app: 'Suprnova' })}</h1>
}
<!-- Vue 3.5 -->
<script setup lang="ts">
import { useLang } from '../lib/lang'
const { t, locale } = useLang()
</script>

<template>
  <h1>{{ t('welcome', { app: 'Suprnova' }) }}</h1>
</template>

Number and date formatting on the client uses the browser's built-in Intl - no ICU data is shipped to the browser.

Typed message keys

suprnova generate-types parses lang/<default locale>/*.ftl and emits a union of every message id alongside the page-props types:

// frontend/src/types/lang-keys.ts
// Generated by `suprnova generate-types` - do not edit.
export type MessageKey =
  | "validation-min"
  | "welcome"

The wrappers type t(key: MessageKey, …), so this is the same promise as inertia-props.ts: rename a message in Rust, regenerate, and the TypeScript compiler points at every call site that still uses the old id. suprnova serve watches lang/ alongside src/, so the file regenerates as you edit catalogs.

A project with no lang/ directory and no message ids gets no file - an app that isn't localized sees no new artifact appear.

Locale-aware formatting

Seven functions on Lang, all ICU4X-backed, all reading the current locale, all with try_* siblings that return Result<String, FrameworkError> instead of degrading:

use suprnova::chrono::NaiveDate;
use suprnova::{DateStyle, Lang, ListStyle, RelativeUnit, TimeStyle};

let dt = NaiveDate::from_ymd_opt(2026, 8, 1)
    .and_then(|d| d.and_hms_opt(14, 30, 0))
    .expect("valid datetime");

Lang::number(1_234_567.89);                          // en-US → 1,234,567.89
                                                     // de-DE → 1.234.567,89
Lang::currency(19.99, "USD");                        // en-US → $19.99
Lang::date(&dt, DateStyle::Long);                    // en-US → August 1, 2026
Lang::time(&dt, TimeStyle::Short);                   // en-US → 2:30 PM
Lang::datetime(&dt, DateStyle::Medium, TimeStyle::Short);
Lang::list(&["Ada", "Grace", "Alan"], ListStyle::And); // → Ada, Grace, and Alan
Lang::relative(-3, RelativeUnit::Day);               // → 3 days ago

The style enums: DateStyle { Full, Long, Medium, Short }, TimeStyle { Medium, Short }, ListStyle { And, Or, Unit }, RelativeUnit { Second, Minute, Hour, Day, Week, Month, Year }. Lang::relative takes a signed amount - negative is the past ("3 days ago"), positive the future ("in 3 days").

Exact output comes from the CLDR data baked into ICU4X and can change across an ICU upgrade, particularly for dates and currency. In your own tests, assert on shape and locale-distinctness (de != en, contains 2026) rather than on exact bytes.

Formatting inside a message

Two functions are callable from FTL:

order-total = Your total is { NUMBER($amount, maximumFractionDigits: 2) }.
published = Published { DATETIME($when, dateStyle: "medium", timeStyle: "short") }
use suprnova::__;

let line = __!("published", when: "2026-08-01T14:30:00");

NUMBER() is Fluent's builtin, registered explicitly, and gives you fraction-digit control inside the message. DATETIME() is Suprnova's: $value accepts an ISO-8601 string or epoch milliseconds, and dateStyle / timeStyle take the same names as the Rust enums, lower case. A value it cannot parse passes through verbatim with a warn! - a Fluent function cannot return an error, and a rendered page with one odd-looking date beats a 500.

When you want ICU4X's full formatting rather than what a Fluent function exposes, format in Rust and pass the finished string in:

use suprnova::{__, Lang};

let total = __!("order-total-text", amount: Lang::currency(19.99, "USD"));

Testing your translations

Two helpers do the work: use_lang_path points the loader at a fixture directory, and scope_locale pins the current locale for the duration of a future.

The hermetic form - build a translator over a fixture directory and bind it in a test-scoped container - is what the framework's own tests use, because it touches no process-global state and survives parallel test execution:

use std::sync::Arc;
use suprnova::testing::TestContainer;
use suprnova::{scope_locale, FluentTranslator, Lang, Locale, LocalizationConfig, Translator};

#[tokio::test]
async fn spanish_greeting_comes_from_the_catalog() {
    let _guard = TestContainer::fake();

    let config = LocalizationConfig::from_env().expect("locale config");
    let translator = FluentTranslator::from_dir("tests/fixtures/lang", &config)
        .expect("load catalogs");
    TestContainer::bind::<dyn Translator>(Arc::new(translator));

    scope_locale(Locale::parse("es").expect("locale"), async {
        assert_eq!(Lang::get("welcome"), "¡Bienvenido!");
        assert_eq!(Lang::locale().as_str(), "es");
    })
    .await;
}

use_lang_path is the right tool when the test boots the real application and you want the whole app pointed at fixtures:

use suprnova::use_lang_path;

#[tokio::test]
async fn app_boots_against_fixture_catalogs() {
    use_lang_path("tests/fixtures/lang");
    // … boot the app; `lang_path("")` now resolves to the fixture dir.
}

It writes a process-global path override, so treat it as a per-binary setting rather than something two parallel tests can disagree about.

Detection itself - the session/cookie/Accept-Language chain - is worth testing through the real pipeline rather than by calling the middleware directly, because the interesting cases are about header parsing and about which source wins. Mount a route whose handler returns __!("welcome"), register LocaleMiddleware in the MiddlewareRegistry, and drive it with the loopback harness from HTTP Tests, sending Accept-Language: fr, es;q=0.8 and asserting on the Spanish body. The cases worth pinning: a header negotiates, a cookie beats a header, an unavailable locale is skipped rather than erroring, and a malformed header still returns 200.

See Testing for TestContainer::scope when your test runs on a multi-threaded runtime - the thread-local fake() guard above does not survive a future migrating between workers.

Why Suprnova diverges

FTL files, not PHP arrays. Laravel has two formats - nested arrays in lang/en/messages.php, plus flat JSON in lang/en.json for string-keyed translations - and neither is loadable by a browser, nor expresses plural selection in the file: that lives in trans_choice's pipe-and-range convention inside the string. Fluent gives us one format that the server and the client both parse, which is what makes "the frontend shows the same string the validator produced" a property of the design rather than a convention you maintain. It costs you a new syntax to learn (this chapter is most of it) and a tooling change: Poedit can't edit .ftl, while Crowdin, Weblate, Lokalise, and Pontoon can. It also costs dotted namespacing - trans('messages.welcome') has no equivalent, because ids are a flat namespace per locale. Prefix instead.

No trans_choice. Laravel selects a plural form with pipe-separated strings and explicit ranges:

// Laravel
trans_choice('{1} plik|[2,4] pliki|[5,*] plików', $count);

Now count to 22 in Polish. CLDR puts 22 in the few category - 22 pliki - but [5,*] swallows it and produces 22 plików. The same break happens at 32, 42, 102, and in Russian, Arabic, Czech, Lithuanian, and Welsh, each in its own places. Integer ranges cannot express plural rules, because plural rules are not about ranges; they're about the last digit, the last two digits, and in some languages whether the value is an integer at all. Fluent selects on the CLDR category directly, so $count is an ordinary argument and the translator - the person who knows the language - writes all four of Polish's categories:

files =
    { $count ->
        [one] { $count } plik
        [few] { $count } pliki
        [many] { $count } plików
       *[other] { $count } pliku
    }

one is 1; few is 2–4, 22–24, 32–34, 102–104; many is 0, 5–21, 25–31; other catches the fractions (1,5 pliku) and carries the default marker, per the rule above.

Laravel's rangeless form (plik|pliki|plików) does better - it consults a per-language index and picks the nth segment - but that index is a hand-maintained table rather than CLDR data, it offers Polish three segments where CLDR defines four categories, the segments are positional with no category names to review, and it can only ever select on the count.

Which is the second benefit, falling out for free: a Fluent selector can switch on any argument, not just a count. Gender, plan tier, and connection state select the same way, and none of them needed a new facade method.

Isolation marks are off by default. Fluent normally wraps every interpolation in U+2068 (FIRST STRONG ISOLATE) and U+2069 (POP DIRECTIONAL ISOLATE), so that a right-to-left value embedded in a left-to-right sentence renders in the right order. Correct - and invisible, which means every assert_eq!("Hello Ada", …) in an English-only app fails with two characters nobody can see in the diff. We default them off and make turning them on one call:

let config = LocalizationConfig::from_env()?.use_isolating(true);

Turn them on when you ship an RTL locale - Arabic, Hebrew, Persian, Urdu - or any locale where user-supplied values mix scripts inside a sentence. Then update your assertions to compare against strings that carry the marks, or strip them in the assertion helper. The default optimises for the common case; the correct case is one line away and this paragraph is the reminder to take it.

Next

  • Validation - rules, the validate! macro, and where ValidationMessage comes from
  • TypeScript Types - generate-types, inertia-props.ts, and lang-keys.ts
  • Middleware - ordering LocaleMiddleware against the rest of the global chain
  • Session - the store the first detection step reads
  • Environment Variables - APP_LOCALE, APP_FALLBACK_LOCALE, APP_LOCALE_PARENTS, APP_BASE_PATH
  • Testing - TestContainer, #[suprnova_test], and hermetic DI overrides