Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Delivery modes

Leptos can deliver an application in four ways, and this library supports all four. They differ in what runs in the browser and in how a language switch happens:

ModeThe browser getsA language switchStart from
SSR + hydrate (the default)the whole application as wasmlive, no reloadGetting started
…with lazy routesthe same, split into chunks fetched per routelive, no reloadbelow
Islandsonly the interactive partsthe form’s ?lang= and a new page, remembered in a cookiebelow
Client-onlythe whole application, no serverlive, remembered in the browserbelow

In every mode, the wasm contains no message text, ids, argument names or plural rules. A translation edit leaves it byte-for-byte the same, so every reader’s cached copy stays valid.

SSR + hydrate

This is Getting started: the server negotiates the language, renders the page in it, and embeds the catalogs. The browser loads the page’s catalog (preloaded by the page, in parallel with the wasm), hydrates, and switches language live. Use it unless you have a reason to use one of the others.

One crate is enough. The catalogs are embedded in the server binary only: the generated module’s list of catalog files is compiled only with ssr. So a translation edit changes the server and leaves the wasm alone, with no extra build step.

Lazy routes

With cargo leptos --split, a #[lazy_route] becomes a wasm chunk of its own, fetched the first time the route is matched. Descriptions inside the chunk read the catalog the main module installed, join the same node registry, and follow a switch like everything else. When the route unmounts, they free their registry slots.

Two things change from Getting started: the route, and the entry point, hydrate_lazy. The shell is unchanged:

use leptos::prelude::*;
use leptos_meta::{MetaTags, Title, provide_meta_context};
use leptos_router::components::{A, Route, Router, Routes};
use leptos_router::{Lazy, LazyRoute, lazy_route, path};
use mf2::leptos::{CatalogLinks, CatalogPreload, LocaleSwitcher, html_lang};

mf2::include_generated!();

pub fn shell(options: LeptosOptions) -> impl IntoView {
    let (lang, dir) = html_lang();
    view! {
        <!DOCTYPE html>
        <html lang=lang dir=dir>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options />
                <MetaTags />
                <CatalogPreload />
                <CatalogLinks />
            </head>
            <body>
                <App />
            </body>
        </html>
    }
}

The application has a second route, whose view is a LazyRoute:

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();
    view! {
        <Title text=tr!("app-title") />
        <Router>
            <header>
                <LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
            </header>
            <nav>
                <A href="/">{tr!("app-title")}</A>
                " "
                <A href="/visits">{tr!("visit-again")}</A>
            </nav>
            <main>
                <Routes fallback=|| view! { <p>{tr!("not-found")}</p> }>
                    <Route path=path!("/") view=|| view! { <h1>{tr!("greeting", name = "Ada")}</h1> } />
                    // Under `--split`, this view is a wasm chunk of its own.
                    <Route path=path!("/visits") view={Lazy::<Visits>::new()} />
                </Routes>
            </main>
        </Router>
    }
}

pub struct Visits;

#[lazy_route]
impl LazyRoute for Visits {
    fn data() -> Self {
        Visits
    }

    fn view(this: Self) -> AnyView {
        let Visits = this;
        let count = RwSignal::new(1);
        view! {
            <p role="status">{tr!("visits", count = count)}</p>
            <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
        }
        .into_any()
    }
}

hydrate_lazy replaces hydrate_body. It does the same thing, and in addition, when the page being loaded is a lazy route, it loads that route’s chunk before hydration reaches it:

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::hydrate_lazy(App);
}

Leptos hydrates a lazy route only with its lazy feature, so the client’s feature list gains leptos/lazy. Without it, the page panics when hydration reaches the route:

[features]
hydrate = [
    "leptos/hydrate",
    "leptos/lazy",
    "mf2/hydrate",
    "dep:console_error_panic_hook",
    "dep:wasm-bindgen",
]

Run it with cargo leptos watch --split, and build it with cargo leptos build --split. The flag is not optional here: with #[lazy_route] and leptos/lazy, a build without it leaves the JavaScript importing a placeholder (__wasm_split_placeholder__) that the browser cannot resolve, and nothing hydrates on any page. This is Leptos’s behaviour, not this library’s.

Islands

In an islands application, only the components marked #[island] are compiled to wasm. Everything else renders on the server and ships no code at all. A server-only component costs the wasm nothing, however many messages it uses. cargo xtask islands-zero measured this on the islands example (2026-09-28). It adds a server-only component with a call site in every position (text, attribute, argument, markup) and compares the client with and without it. The code section was 165,705 bytes with 864 functions both times, and the data section 23,446 bytes both times, so the wasm shipped 85,726 bytes gzipped both times. The two files are not identical byte for byte, but no section grew. (An earlier run, 2026-09-27, saw the data section grow by 8 bytes: a few bytes of data at most, and no code.)

Islands change the trade-off for switching. Most of the page has no client code, so it cannot follow a live switch. The documented default is therefore the static-locale feature: the switcher’s form submits ?lang=, and the server renders the whole page in the new language, server-only parts included, and remembers the choice in the cookie. Turn on islands in Leptos and static-locale in mf2:

[dependencies]
leptos = { version = "0.9.0-beta", default-features = false, features = ["islands"] }
mf2 = { version = "2", features = ["leptos", "fn-number", "static-locale"] }

static-locale applies to the server and the client alike. Nothing on the client registers to follow the language, except a node with a signal-valued argument (it still has to re-format when the signal changes).

The islands gate. A message with markup takes its element structure from the catalog, so an island that contains one must not hydrate before the catalog has arrived. Leptos hydrates islands one at a time, in page order, and waits for any island that returns a promise. <IslandsGate/> is an empty island, placed first in <body>, that waits for the catalog. Every island after it then hydrates against the page’s catalog. It costs no extra request, and a few bytes of page:

use leptos::prelude::*;
use leptos_meta::{MetaTags, Title, provide_meta_context};
use mf2::leptos::{CatalogPreload, IslandsGate, LocaleSwitcher, html_lang};

mf2::include_generated!();

pub fn shell(options: LeptosOptions) -> impl IntoView {
    let (lang, dir) = html_lang();
    view! {
        <!DOCTYPE html>
        <html lang=lang dir=dir>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options islands=true />
                <MetaTags />
                // No `<CatalogLinks/>`: a switch reloads, and the server
                // writes the new page's preload.
                <CatalogPreload />
            </head>
            <body>
                // First, and outside every island.
                <IslandsGate />
                <App />
            </body>
        </html>
    }
}

The page is an ordinary component, so it renders on the server only. The switcher is not an island either: under static-locale its form submits ?lang=…, and the server does the rest:

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();
    view! {
        <Title text=tr!("app-title") />
        <header>
            <LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
        </header>
        <main>
            <h1>{tr!("greeting", name = "Ada")}</h1>
            <Visits />
        </main>
    }
}

/// The one part that runs in the browser.
#[island]
fn Visits() -> impl IntoView {
    let count = RwSignal::new(1);
    view! {
        <p role="status">{tr!("visits", count = count)}</p>
        <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
    }
}

The client’s entry point installs what the build generated and starts loading the catalog (hydrate_islands). The application also has to export the gate’s island function itself, because mf2 forbids the unsafe code that a #[wasm_bindgen] export expands to. islands_gate!() writes it:

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::hydrate_islands();
}

// The island `<IslandsGate/>` renders.
mf2::leptos::islands_gate!();

The server is the same as in Getting started. Islands change the client, not the server.

Without static-locale. The islands then register their nodes to follow a live switch, but the switch does not become live: the switcher is not an island, so pressing it still submits ?lang= and loads a new page, and an island’s state (a counter, say) starts again. The registration costs wasm and memory for nothing, so keep static-locale on an islands application.

Client-only

A client-only application has no server. The browser does everything, and any static file host can serve the site. Three things are different:

  • The language comes from the browser: the one the reader chose last time (kept in localStorage), else the one that best serves navigator.languages (fr-CA finds fr), chosen as a server chooses from Accept-Language, else the source language. A switch is live, and is remembered. To choose as a server does, by CLDR’s language-matching data, the page needs the part of that data its languages need, which the build generates, and a client-only build’s generated setup() carries. A Setup built by hand without it (.with_language_matching(&LANGUAGE_MATCHING)) finds only a locale of the reader’s own language (fr-CA still finds fr).
  • The catalogs are published beside the wasm, by mf2 compile --site. There is no server to embed them in. With csr, the build script generates only the module, so the wasm names no catalog file.
  • The page finds them through a small index, i18n/index.json. The page preloads it, so it downloads in parallel with the wasm.

The application’s manifest turns on csr everywhere:

[package]
name = "hello-csr"
version = "0.1.0"
edition = "2024"

[dependencies]
console_error_panic_hook = "0.1"
leptos = { version = "0.9.0-beta", features = ["csr"] }
leptos_meta = "0.9.0-beta"
mf2 = { version = "2", features = ["leptos", "csr", "fn-number"] }

[build-dependencies]
mf2-build = "2"

[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true

The build script and the messages are Getting started’s, unchanged.

The application mounts through the same gate as hydration. mount_to_body loads the index, chooses the language from those it lists, loads that language’s catalog, sets <html lang dir>, and only then mounts. If the boot fails, it logs one mf2: line and mounts nothing:

use leptos::prelude::*;
use leptos_meta::{Title, provide_meta_context};
use mf2::leptos::LocaleSwitcher;

mf2::include_generated!();

#[component]
fn App() -> impl IntoView {
    provide_meta_context();
    let count = RwSignal::new(1);
    view! {
        <Title text=tr!("app-title") />
        <header>
            <LocaleSwitcher label=tr!("language.label") button=tr!("language.apply") />
        </header>
        <main>
            <h1>{tr!("greeting", name = "Ada")}</h1>
            <p role="status">{tr!("visits", count = count)}</p>
            <button on:click=move |_| *count.write() += 1>{tr!("visit-again")}</button>
        </main>
    }
}

fn main() {
    console_error_panic_hook::set_once();
    install();
    mf2::leptos::mount_to_body(App);
}

With Trunk, index.html preloads the index:

<!DOCTYPE html>
<!-- The boot sets `lang` and `dir` to the language it chooses. -->
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Hello, MessageFormat 2</title>
    <link rel="preload" as="fetch" crossorigin="anonymous" href="i18n/index.json" data-mf2-index />
    <link data-trunk rel="rust" data-bin="hello-csr" />
  </head>
  <body></body>
</html>

and a hook publishes the catalogs into the site after each build:

[build]
target = "index.html"
dist = "dist"

[[hooks]]
stage = "post_build"
command = "sh"
command_arguments = ["-c", "mf2 compile --site \"$TRUNK_STAGING_DIR/i18n\""]

mf2 compile --site writes each catalog (with .br and .gz versions), named by its content hash, and index.json. It builds the catalogs for mf2’s features in this crate as cargo resolves them, so the catalogs and the wasm always agree on which functions exist. If you pass a --features list that disagrees with cargo’s, it fails and prints both lists. Serve the catalogs with a long cache lifetime, since their names change when their content does, and serve index.json with no-cache.

Booting costs two requests one after the other: the index, then one catalog. SSR costs one. The index starts downloading with the wasm, not after it.

trunk serve runs it locally; trunk build --release writes the site to dist/.