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
ssrfeature flag ofleptos, thessrfeature flag ofleptos_routerand themacrosandrt-multi-threadfeature flags oftokioto be enabled inCargo.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.