Switching language
This page covers how a request’s language is chosen, the switcher the library provides, what a switch does, and how to build your own control. Its samples join the call sites library, with these messages:
[language]
label = Language
apply = Apply
en = English
fr = Français
How the server chooses
On a server, mf2::axum’s Negotiator chooses each request’s language. It
tries an ordered list of sources, and the first one that names a
language this build has wins (fr-CA finds fr). Sinks record the
result in the response — CookieLocale as a sink writes the cookie only
for an explicit choice, a language that came from ?lang= or the path,
never for a guess from Accept-Language or the default:
| Source | Reads |
|---|---|
QueryParam | ?lang=fr (the name can be changed): how a link, a test, or the switcher’s form chooses |
CookieLocale | the mf2_locale cookie: a choice the reader made earlier |
AcceptLanguage | the browser’s languages, in quality order |
PathPrefix | /fr/…, for a site whose languages have their own URLs |
If no source matches, the source language is used, unless
.default_locale(…) names another. The response carries
Content-Language and a Vary that names each source’s header, and
Cookie as well, because the server also reads the reader’s time zone
from the mf2_tz cookie. The
server is the only place a language is negotiated. The client reads it
from the page (<html lang> and the preload link), so hydration cannot
choose differently.
Negotiator::default(), which Getting started uses, reads the query,
then the cookie, then Accept-Language. A site with a language in its URLs puts the path first.
A ?lang= then cannot change the language of /en/…, so the switcher’s
form, which submits one, needs the server to send it on to the other
language’s URL: path_prefix_redirect answers /en/page?lang=fr with a
redirect to /fr/page. It reads the query’s name from the negotiator, so
it goes under it: add it to the router before the negotiator.
/// A site whose pages live under `/en/…` and `/fr/…`.
#[cfg(feature = "ssr")]
pub fn path_negotiator() -> mf2::axum::Negotiator {
use mf2::axum::{AcceptLanguage, CookieLocale, Negotiator, PathPrefix, QueryParam};
Negotiator::empty()
.source(PathPrefix)
.source(QueryParam::default())
.source(CookieLocale::default())
.source(AcceptLanguage)
.sink(CookieLocale::default())
}
/// The redirect under the negotiator, as layers on the application's router.
#[cfg(feature = "ssr")]
pub fn with_path_redirect(router: axum::Router) -> axum::Router {
router
.layer(axum::middleware::from_fn(mf2::axum::path_prefix_redirect))
.layer(path_negotiator())
}
Such a site should also tell search engines about the other languages’
URLs. <AlternateLinks/> in the <head> writes one
<link rel="alternate" hreflang> per language, plus an x-default:
#[component]
pub fn Alternates() -> impl IntoView {
view! {
<mf2::leptos::AlternateLinks href_of=|tag| format!("https://example.com/{tag}/") />
}
}
The server’s options
Negotiator::default() is what a site gets when it says nothing more:
?lang=, the cookie, then Accept-Language, with the cookie as its sink,
Secure except in a debug build. A site that wants another order or
another source starts from Negotiator::empty() and lists its sources.
Each part can be changed:
QueryParam("hl")reads another parameter name thanlang.QueryParam::default()islang.<LocaleSwitcher>’s form submits under the name of the negotiator’s firstQueryParam.CookieLocale’s fields:name(mf2_locale),max_age(a year, in seconds),path(/),same_site(Lax) andsecure(true). The client writes the cookie undermf2_localeon every switch, so a differentnameis for an extra source that reads a cookie another system wrote, listed after the default one. ASecurecookie is not stored from a page served over plain HTTP — browsers make an exception for127.0.0.1andlocalhost— so a development server reached without TLS at any other address (a phone on the local network, say) setssecure: false(Negotiator::default()ties it todebug_assertions)..default_locale("fr")answers a request no source matched in French instead of the source language, if the build has French.Negotiator::over(locales, default)starts from an explicit table of tags and directions instead of the build’s, for a site that offers fewer languages than it built, or a test.Negotiator::locales()(and, anywhere on the server or the client,mf2::leptos::locales()) returns the table in use: the tags and their directions, in build order — for a sitemap or a list ofhreflanglinks.
/// `?hl=` first, a cookie an older version of the site wrote after this
/// library's own, and French for a request nothing matches.
#[cfg(feature = "ssr")]
pub fn custom_negotiator() -> mf2::axum::Negotiator {
use mf2::axum::{AcceptLanguage, CookieLocale, Negotiator, QueryParam};
Negotiator::empty()
.source(QueryParam("hl"))
.source(CookieLocale::default())
.source(CookieLocale {
name: "site_lang",
..CookieLocale::default()
})
.source(AcceptLanguage)
.sink(CookieLocale {
secure: !cfg!(debug_assertions),
..CookieLocale::default()
})
.default_locale("fr")
}
What was negotiated. provide_locale returns it, and
mf2::axum::negotiated() gives it to any component that renders in the
request: the tag, its direction, from (the source that matched, such as
"query" or "cookie", or "default") and matched.
/// Says where the page's language came from, on the server.
#[cfg(feature = "ssr")]
#[component]
pub fn LanguageOrigin() -> impl IntoView {
let from = mf2::axum::negotiated().map_or("default", |n| n.from);
view! { <meta name="language-origin" content=from /> }
}
Your own sources and sinks. A source implements LocaleSource: a
name, the request header it reads (for Vary), and the tags a request
offers, best first. A sink implements LocaleSink: a name, and the header
it adds to the response for what was negotiated. A subdomain as a source:
/// `fr.example.com` → `fr`.
#[cfg(feature = "ssr")]
#[derive(Debug)]
pub struct Subdomain;
#[cfg(feature = "ssr")]
impl mf2::axum::LocaleSource for Subdomain {
fn name(&self) -> &'static str {
"subdomain"
}
fn vary(&self) -> Option<axum::http::HeaderName> {
Some(axum::http::header::HOST)
}
fn candidates<'r>(
&self,
parts: &'r axum::http::request::Parts,
out: &mut Vec<std::borrow::Cow<'r, str>>,
) {
let host = parts.headers.get(axum::http::header::HOST).and_then(|h| h.to_str().ok());
if let Some((first, _)) = host.and_then(|h| h.split_once('.')) {
out.push(std::borrow::Cow::Borrowed(first));
}
}
}
The switcher
<LocaleSwitcher> is the switcher the library provides. It is a
<form method="get">. Inside it are a <select name="lang"> (named for
the negotiator’s QueryParam) in its own
<label>, and a submit button whose text you supply. With no children it
offers every language the build has, each named by its language.<tag>
message, so adding a language needs no code:
#[component]
pub fn Header() -> impl IntoView {
view! {
<header>
<mf2::leptos::LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
</header>
}
}
A language without a language.<tag> message is shown by its tag, and the
build warns about it.
Nothing happens until the button is pressed. A <select> fires
change on every arrow key, so a switcher that switched on change would
change the page’s language at each keypress while a keyboard user moved
through the list (WCAG 3.2.2). What pressing the button does depends on
what the page runs:
| The page | Pressing the button |
|---|---|
hydrated (hydrate) or client-only (csr) | switches live, with focus left on the button |
hydrated with static-locale | writes the cookie and reloads in the new language |
| not yet hydrated, hydration failed, or no wasm | submits ?lang=fr, which the server negotiates |
The last row is why it is a form. The switcher works before the wasm
arrives and when it never does, and on an islands page it needs no island:
there the switcher is not an island, so no client code runs, and the form’s
?lang= and the server’s cookie are the switch.
On a site whose languages live in its URLs, give the switcher
href_of, the URL of the current page in a language — the shape
<AlternateLinks/> takes. Each option then carries its language’s URL,
and pressing the button goes there instead of switching in place. Without
the wasm, the form’s ?lang= goes to the server, and
path_prefix_redirect (above) sends it on to the same URL. This sample
also makes a list of its own: <LocaleOption> children, whose tag is
the generated Locale or a tag as a string:
/// This page's URL in `tag`, on a site under `/en/…` and `/fr/…`.
fn account_href(tag: &str) -> String {
format!("/{tag}/account")
}
#[component]
pub fn AccountHeader() -> impl IntoView {
view! {
<mf2::leptos::LocaleSwitcher
label=tr!("language.label")
button=tr!("language.apply")
href_of=account_href
>
<mf2::leptos::LocaleOption tag=Locale::En>{Locale::En.name()}</mf2::leptos::LocaleOption>
<mf2::leptos::LocaleOption tag="fr">{tr!("language.fr")}</mf2::leptos::LocaleOption>
</mf2::leptos::LocaleSwitcher>
}
}
The option for the page’s language is selected in the server’s HTML.
Each option carries its own lang, so a screen reader reads “Français”
with a French voice. Each option’s text is a message: language.fr is
Français in every catalog, so each language is named in its own
language, and none of the names is compiled into the wasm. The switcher
has no fixed id, so a page can have two (a header and a footer).
The form has the class mf2-locale-switcher. A flexbox layout that works
in both directions:
.mf2-locale-switcher {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.mf2-locale-switcher label {
display: flex;
align-items: center;
gap: 0.5rem;
}
What a switch does
A live switch (set_locale(Locale::Fr), generated beside tr!) does this,
in order:
- it finds the French catalog’s URL: from the page’s
<CatalogLinks/>if the shell renders them, or else by askingGET /i18n/fr, which redirects to it (one extra round trip, only at switch time). A client-only application finds it in the site’sindex.json, and has no/i18n/<tag>to ask; - it fetches the catalog and checks it against the build. A catalog from another deploy (the server was redeployed since the page loaded) is never read: the choice is remembered as in step 6, and the page reloads into the new language, which the new deploy’s server renders;
- it installs the catalog and rewrites every registered text node, attribute and markup fragment directly, with no effect per call site;
- it notifies derived props (
TextProp,Signal<String>) and closures that read a string; - it sets
<html lang dir>, so an Arabic page turns right-to-left from thedirattribute alone; - it remembers the choice for the next visit: the cookie on a
server-rendered page,
localStoragein a client-only application.
If any other step before the install fails, the page stays as it was and
set_locale logs one line, beginning mf2:, to the console.
Your own control
The simplest control that works everywhere is a link per language. It navigates, so the page reloads in the new language, and it needs no client code:
#[component]
pub fn LanguageLinks() -> impl IntoView {
view! {
<nav aria-label=tr!("language.label")>
<ul class="languages">
<li><a href="?lang=en" hreflang="en" lang="en">{tr!("language.en")}</a></li>
<li><a href="?lang=fr" hreflang="fr" lang="fr">{tr!("language.fr")}</a></li>
</ul>
</nav>
}
}
To switch live from your own control, call the generated set_locale. preload_locale fetches and checks a catalog without
switching to it. Call it when the pointer or keyboard focus reaches a
control, so that the switch itself is instant. Both are callable on the
server too, where they do nothing, so a component needs no #[cfg]:
/// A button that switches live. It needs client code, so it does nothing
/// before hydration: prefer `LocaleSwitcher` unless the page cannot work
/// without the wasm anyway.
#[component]
pub fn SwitchButton(lang: Locale, children: Children) -> impl IntoView {
view! {
<button
type="button"
lang=lang.tag()
on:click=move |_| set_locale(lang)
on:pointerenter=move |_| preload_locale(lang)
on:focus=move |_| preload_locale(lang)
>
{children()}
</button>
}
}
The current language
The generated current_locale() is the language the page is in. In the
browser it is reactive: a view or an effect that reads it follows the next
switch. On the server it is the request’s language. As a closure it keeps
an attribute in step, content=move || current_locale().tag(); the
accessibility page uses it for schema.org’s
inLanguage.