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
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
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.