Full-stack with Leptos & Dioxus
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Leptos 0.8.22 with leptos_axum 0.8.10, Dioxus 0.7.10 and axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Full-stack Rust frameworks compile the same components twice: to native code on the server, which renders HTML and handles data access, and to WebAssembly in the browser, which takes over the rendered page ("hydration") and makes it interactive. Both major frameworks use axum as their server. Rust and WebAssembly in general are covered on the Rust Reference’s WebAssembly page.
Leptos with leptos_axum
Leptos is a fine-grained reactive framework (signals, no virtual DOM) with JSX-like view! templates. leptos_axum
adds its routes to an axum Router and exposes server functions: async functions marked #[server] whose body only
exists in the server build; in the browser build the macro replaces the body with an HTTP call to an automatically
registered endpoint.
[dependencies]
axum = "0.8.9"
leptos = { version = "0.8.22", features = ["ssr"] } # "hydrate" in the WebAssembly build
leptos_axum = "0.8.10"
leptos_router = { version = "0.8", features = ["ssr"] }
tokio = { version = "1.53", features = ["full"] }
use axum::Router;
use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list};
use leptos_router::{
components::{Route, Router as LeptosRouter, Routes},
path,
};
/// Runs on the server; the client calls it over HTTP (POST /api/count_books...).
#[server]
pub async fn count_books(prefix: String) -> Result<usize, ServerFnError> {
let books = ["Dune", "Dune Messiah", "Kindred"];
Ok(books.iter().filter(|b| b.starts_with(&prefix)).count())
}
#[component]
fn Catalogue() -> impl IntoView {
let count = Resource::new(|| (), |_| count_books("Dune".to_owned()));
view! {
<h1>"Catalogue"</h1>
<Suspense fallback=|| view! { <p>"Loading..."</p> }>
{move || count.get().map(|n| view! { <p>{format!("{} books start with Dune", n.unwrap_or(0))}</p> })}
</Suspense>
}
}
#[component]
fn App() -> impl IntoView {
view! {
<LeptosRouter>
<Routes fallback=|| "Not found.">
<Route path=path!("/") view=Catalogue />
</Routes>
</LeptosRouter>
}
}
fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<HydrationScripts options />
</head>
<body>
<App />
</body>
</html>
}
}
#[tokio::main]
async fn main() {
let options = LeptosOptions::builder().output_name("bookshelf").site_addr(([127, 0, 0, 1], 3000)).build();
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes(&options, routes, {
let options = options.clone();
move || shell(options.clone())
})
.with_state(options.clone());
let listener = tokio::net::TcpListener::bind(options.site_addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: The Leptos book and leptos_axum 0.8.10
$ curl -s localhost:3000/ | grep -o '<h1>.*</p>'
<h1>Catalogue</h1><!--s-1-o--><p>Loading...</p><!--s-1-c--></body></html><template id="1-f"><p>2 books start with Dune</p>
Source: The Leptos book and leptos_axum 0.8.10
The server streams the page: the shell with the Suspense fallback is sent immediately, and the resolved fragment
follows in a <template> once count_books finishes, so slow data does not delay first paint. The browser half is
built separately for wasm32-unknown-unknown with the hydrate feature; cargo-leptos orchestrates both builds,
serves the generated /pkg assets and reloads on change. Because the Router returned by leptos_routes is an
ordinary axum Router, the rest of Bookshelf — the JSON API, sessions, middleware — merges into the same
application.
Dioxus fullstack
Dioxus uses a virtual DOM with an rsx! macro and targets web, desktop and mobile from one code base. Version 0.7 made
server functions look like axum routes: #[get("/api/books/count")] declares the HTTP method and path explicitly, and
dioxus::server::router(app) returns an axum Router serving server-side rendering and every registered server
function, to which ordinary axum routes can be added:
[dependencies]
dioxus = { version = "0.7.10", features = ["fullstack"] }
[features]
server = ["dioxus/server"] # the native server build
web = ["dioxus/web"] # the WebAssembly client build
Source: Dioxus 0.7 guide and Cargo: features
use dioxus::prelude::*;
/// A server function exposed as GET /api/books/count: compiled into the server, called over HTTP by the client.
#[get("/api/books/count")]
async fn count_books() -> Result<usize> {
Ok(3)
}
fn app() -> Element {
let count = use_server_future(count_books)?;
rsx! {
h1 { "Catalogue" }
match count() {
Some(Ok(n)) => rsx! { p { "{n} books" } },
Some(Err(e)) => rsx! { p { "error: {e}" } },
None => rsx! { p { "Loading..." } },
}
}
}
fn main() {
#[cfg(not(feature = "server"))]
dioxus::launch(app);
// Server build: mount the Dioxus app (SSR + server functions) inside a regular axum Router.
#[cfg(feature = "server")]
dioxus::serve(|| async move {
let router = dioxus::server::router(app).route("/health", dioxus::server::axum::routing::get(|| async { "ok" }));
Ok(router)
});
}
Source: Dioxus 0.7 guide and dioxus 0.7.10
$ curl -s localhost:8080/api/books/count
3
$ curl -s localhost:8080/health
ok
Source: Dioxus 0.7 guide and dioxus 0.7.10
The dx CLI (dx serve --platform web) builds both halves, bundles assets into a public directory next to the server
binary and hot-reloads rsx! changes. The server binds to IP/PORT (default 127.0.0.1:8080).
Dependency caveats
At the time of writing both frameworks pull in tower-http 0.6 (and Dioxus also axum-extra 0.10), while this section
uses tower-http 0.7 and axum-extra 0.12. Cargo builds both versions side by side; that works, but costs compile time and
means tower-http types from one version cannot be passed to APIs expecting the other. Keep the framework’s router and
your own layers each on their own version, or use the framework’s re-exports (dioxus::server::axum) where you
interact with it. Check cargo tree -d when upgrading.
axum in WebAssembly, without tokio
axum’s routing does not depend on tokio. With default features disabled, a Router compiles to wasm32-unknown-unknown
and can handle requests that arrive from JavaScript — in a service worker, an edge runtime or an in-browser mock API — as the official simple-router-wasm example shows:
[dependencies]
axum = { version = "0.8.9", default-features = false, features = ["json"] } # no "tokio": no axum::serve
futures-executor = "0.3"
tower-service = "0.3"
Source: axum example: simple-router-wasm
use axum::{Router, body::Body, extract::Path, http::Request, routing::get};
use tower_service::Service;
/// The same Router, compiled to WebAssembly without tokio's networking: requests come from JavaScript
/// (a service worker, an edge runtime), not from a socket.
pub fn app() -> Router {
Router::new().route("/books/{isbn}", get(|Path(isbn): Path<String>| async move { format!("book {isbn}") }))
}
pub fn handle(uri: &str) -> String {
let mut router = app();
let request = Request::get(uri).body(Body::empty()).expect("valid request");
let response = futures_executor::block_on(router.call(request)).expect("Router is infallible");
response.status().to_string()
}
Source: axum example: simple-router-wasm
Choosing a front-end approach
| Approach | Strengths | Costs |
|---|---|---|
simplest; one language; tiny client; works without JavaScript |
limited client-side interactivity |
|
Leptos / Dioxus |
one language end to end; shared types and validation; server functions instead of a hand-written API |
WebAssembly bundle size; younger ecosystems; longer builds; framework churn between versions |
JavaScript SPA (React, Vue, Angular) served by |
largest ecosystem and hiring pool; mature tooling |
two languages; the API must be designed and versioned explicitly |
Bookshelf uses askama and htmx for its catalogue and a JSON API that any of the three could consume. Leptos and Dioxus are the natural choice for teams that want Rust on both sides; React is covered in this site’s React section, and desktop GUIs with Tauri or Dioxus belong to a planned Windows Apps section (issue #191).