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

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 leptos_router leptos_axum axum tokio Net

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

This recipe requires the ssr feature flag of leptos, the ssr feature flag of leptos_router and the macros and rt-multi-thread feature flags of tokio to be enabled in Cargo.toml.

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 leptos_axum axum tokio wasm-bindgen tower-http console_error_panic_hook Net

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.7"
wasm-bindgen = "=0.2.129"
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.