Full Stack Web

Leptos is a full stack web framework. The first recipe renders HTML on the server only. The second adds hydration so the client can re-run the same components as WebAssembly for interactivity.

Return filtered results as HTML

leptos-badge leptos-router-badge leptos-axum-badge axum-badge tokio-badge cat-net-badge

The crate uses leptos with the ssr feature, plus leptos_router, leptos_axum, axum, and tokio. It lives outside the main workspace because leptos needs resolver = "3".

Define the router

Router is responsible for what page is shown when the browser hits the app. The path! macro is used to define what URL matches. The :query is a template — it matches anything and stores the value as query for the component to read later.

#[component]
fn App() -> impl IntoView {
    view! {
        <LeptosRouter>
            <nav><A href="/">"Home"</A>" | "<A href="/filter/serde">"serde"</A>" | "<A href="/filter/tokio">"tokio"</A></nav>
            <main>
                <Routes fallback=|| "Not found.">
                    <Route path=path!("") view=HomePage />
                    <Route path=path!("/filter/:query") view=FilterPage />
                </Routes>
            </main>
        </LeptosRouter>
    }
}

<A> anchors links while <Routes fallback=...> is used to provide a default.

More in the Leptos book: Defining <Routes/> and Nested Routing.

Read the parameter and fetch on the server

use_params holds the values from the Router. Resource::new uses a closure to fetch async. The closure runs on the server and <Suspense> lets the user know the server is processing.

#[derive(Params, PartialEq, Clone, Debug)]
struct FilterParams {
    query: Option<String>,
}

#[component]
fn FilterPage() -> impl IntoView {
    let params = use_params::<FilterParams>();
    let query = move || {
        params
            .read()
            .as_ref()
            .ok()
            .and_then(|p| p.query.clone())
            .unwrap_or_default()
    };

    let results = Resource::new(query, filter_crates);

    view! {
        <h1>"Results"</h1>
        <Suspense fallback=|| view! { <p>"Loading..."</p> }>
            {move || results.get().map(|r| match r {
                Ok(items) => view! { <CrateList items /> }.into_any(),
                Err(err) => view! { <p>{format!("error: {err}")}</p> }.into_any(),
            })}
        </Suspense>
    }
}

filter_crates is a plain async fn that runs on the server. It resolves the data file via env!("CARGO_MANIFEST_DIR") so the path works regardless of where the binary runs from.

async fn filter_crates(query: String) -> Result<Vec<String>, String> {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/crates.txt");
    let text = tokio::fs::read_to_string(path)
        .await
        .map_err(|e| e.to_string())?;
    let q = query.to_lowercase();
    Ok(text
        .lines()
        .filter(|line| line.to_lowercase().contains(&q))
        .map(String::from)
        .collect())
}

More in the Leptos book: Params and Queries, Loading Data with Resources, and Suspense.

Write a reusable component

Components are functions annotated with #[component]. Props are ordinary function arguments.

#[component]
fn CrateList(items: Vec<String>) -> impl IntoView {
    view! {
        <p>{format!("{} match(es)", items.len())}</p>
        <ul>
            {items.into_iter().map(|name| view! { <li>{name}</li> }).collect::<Vec<_>>()}
        </ul>
    }
}

More in the Leptos book: Components and Props and Passing Children to Components.

Wire up Axum

generate_route_list(App) walks the Routes tree to enumerate every path the router knows about. leptos_routes registers those paths as Axum GET handlers that render the shell on each request.

fn shell(_options: LeptosOptions) -> impl IntoView {
    view! {
        <!DOCTYPE html>
        <html>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <title>"Crate Filter"</title>
            </head>
            <body><App /></body>
        </html>
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr: std::net::SocketAddr = "127.0.0.1:3000".parse()?;
    let conf = LeptosOptions::builder()
        .output_name("web_leptos")
        .site_addr(addr)
        .build();
    let routes = generate_route_list(App);

    let app = Router::new()
        .leptos_routes(&conf, routes, {
            let opts = conf.clone();
            move || shell(opts.clone())
        })
        .with_state(conf);

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    println!("listening on http://{addr}");
    axum::serve(listener, app).await?;
    Ok(())
}

More in the Leptos book: The Life of a Page Load and cargo-leptos (the tool most real apps graduate to).

Run it:

cargo run --manifest-path crates/web_leptos/Cargo.toml
# http://127.0.0.1:3000/
# http://127.0.0.1:3000/filter/serde
# http://127.0.0.1:3000/filter/tokio

Synchronize component state with the server

leptos-badge leptos-axum-badge axum-badge tokio-badge wasm-bindgen-badge cat-net-badge

Same data/crates.txt as the first recipe, but the user types in a text box and results update as they type — no page reload. The file stays on the server; the query round-trips through a server function.

The recipe uses cargo-leptos as the build tool and a wasm32-unknown-unknown target:

cargo install cargo-leptos
rustup target add wasm32-unknown-unknown

Split ssr and hydrate with feature flags

One crate, two builds. ssr pulls in the server deps and hydrate turns on the WASM client. cargo-leptos drives both from [package.metadata.leptos].

crate-type = ["cdylib", "rlib"]

[dependencies]
leptos = "0.8.19"
leptos_axum = { version = "0.8.9", optional = true }
leptos_router = "0.8.13"
axum = { version = "0.8", optional = true }
tokio = { version = "1.42", features = ["rt-multi-thread", "macros"], optional = true }
tower-http = "0.6"
wasm-bindgen = "=0.2.118"
console_error_panic_hook = "0.1"

[dev-dependencies]
cargo-leptos = "0.3.6"

[features]
hydrate = ["leptos/hydrate"]
ssr = [
    "dep:leptos_axum",
    "dep:axum",
    "dep:tokio",
    "leptos/ssr",
    "leptos_router/ssr",
]

[package.metadata.leptos]
output-name = "web_leptos_hydrate"
site-root = "target/site"
site-pkg-dir = "pkg"
site-addr = "127.0.0.1:3000"
reload-port = 3001
bin-features = ["ssr"]

Put the filter behind a server function

#[server] makes filter_crates a function the client can call. The body only compiles into the ssr build and on hydrate the macro leaves a stub that POSTs to the server. tokio::fs reads the file on the server as before.

#[server]
pub async fn filter_crates(query: String) -> Result<Vec<String>, ServerFnError> {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/crates.txt");
    let text = tokio::fs::read_to_string(path).await?;
    let q = query.to_lowercase();
    Ok(text
        .lines()
        .filter(|line| line.to_lowercase().contains(&q))
        .map(String::from)
        .collect())
}

Bind an input to a resource

The App component is plain reactive Leptos — not behind ssr nor hydrate. signal holds the query, event_target_value pulls the string out of each keystroke, and Resource::new reruns filter_crates whenever the query changes. <Suspense> paints results or the "Searching..." fallback.

#[component]
pub fn App() -> impl IntoView {
    let (query, set_query) = signal(String::new());
    let results = Resource::new(
        move || query.get(),
        |q| async move { filter_crates(q).await },
    );

    view! {
        <h1>"Crate search"</h1>
        <input
            type="text"
            placeholder="Type to filter..."
            prop:value=query
            on:input=move |ev| set_query.set(event_target_value(&ev))
        />
        <Suspense fallback=|| view! { <p>"Searching..."</p> }>
            {move || results.get().map(|r| match r {
                Ok(items) => view! {
                    <p>{format!("{} match(es)", items.len())}</p>
                    <ul>
                        {items.into_iter().map(|name| view! { <li>{name}</li> }).collect::<Vec<_>>()}
                    </ul>
                }.into_any(),
                Err(err) => view! { <p>{format!("error: {err}")}</p> }.into_any(),
            })}
        </Suspense>
    }
}

Server side: render the shell with HydrationScripts

<HydrationScripts> emits the <script> tag that loads the WASM bundle. <AutoReload> ships a reload script when cargo leptos watch is running.

#[cfg(feature = "ssr")]
pub fn shell(options: leptos::config::LeptosOptions) -> impl IntoView {
    view! {
        <!DOCTYPE html>
        <html>
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <AutoReload options=options.clone() />
                <HydrationScripts options />
                <title>"Crate search"</title>
            </head>
            <body><App /></body>
        </html>
    }
}

Client side: hydrate the body

hydrate_body runs the same App function in the browser and attaches it to the server-rendered DOM. #[wasm_bindgen] marks it as the entry point the generated JS will call.

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

Wire up Axum behind the ssr feature

get_configuration reads the [package.metadata.leptos] section. leptos_routes also registers the server function endpoint automatically.

#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    use axum::Router;
    use leptos::config::get_configuration;
    use leptos_axum::{LeptosRoutes, generate_route_list};
    use std::path::Path;
    use tower_http::services::ServeDir;
    use web_leptos_hydrate::{App, shell};

    let conf = get_configuration(None)?;
    let options = conf.leptos_options;
    let addr = options.site_addr;
    let routes = generate_route_list(App);

    let app = Router::new()
        .leptos_routes(&options, routes, {
            let opts = options.clone();
            move || shell(opts.clone())
        })
        .nest_service("/pkg", ServeDir::new(Path::new(&*options.site_root).join("pkg")))
        .with_state(options);

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    println!("listening on http://{addr}");
    axum::serve(listener, app).await?;
    Ok(())
}

#[cfg(not(feature = "ssr"))]
fn main() {}

Run it:

cd crates/web_leptos_hydrate
cargo leptos watch
# http://127.0.0.1:3000/

More in the Leptos book: Server Functions and cargo-leptos.