Server-Side Rendering with Leptos
This page answers one task: a web app written in Rust with Leptos should send fully rendered HTML on the first request — for fast first paint and search engines — and then become interactive in the browser through a WebAssembly bundle that hydrates the same components.
Prerequisites
- [ ] Rust with the
wasm32-unknown-unknowntarget, andcargo-leptos(cargo install cargo-leptos). - [ ] Familiarity with Leptos components and signals, as in building a UI with Leptos.
- [ ] A server framework integration — Axum or Actix — chosen when creating the project.
One codebase, two builds
A Leptos SSR application is compiled twice from the same crate. The server build (native code, feature ssr) runs components to produce HTML strings,
executes server functions and serves the app. The client build (WebAssembly, feature hydrate) runs the same components in the browser, but instead of
creating the DOM from scratch, it hydrates: it walks the server-rendered DOM, attaches event listeners and reactive bindings, and takes over. The user
sees content as soon as the HTML arrives; interactivity arrives when the Wasm bundle has loaded and hydrated.
cargo-leptos orchestrates both builds, the asset pipeline and a development server with hot reload. Conditional compilation with the two features keeps
server-only code — database access, secrets — out of the Wasm bundle, and browser-only code out of the server.
Step 1 — create the project and understand the feature split
cargo leptos new --git https://github.com/leptos-rs/start-axum
cd my-app && cargo leptos watch
Cargo.toml declares both features and tells cargo-leptos which to use for each side:
[features]
hydrate = ["leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen"]
ssr = ["dep:axum", "dep:tokio", "leptos/ssr", "dep:leptos_axum"]
[package.metadata.leptos]
bin-features = ["ssr"]
lib-features = ["hydrate"]
lib-profile-release = "wasm-release"
The library is compiled to Wasm with hydrate; the binary is compiled natively with ssr. Dependencies that only make sense on one side are optional and
enabled by the matching feature, so the server’s database driver never appears in the Wasm bundle.
Step 2 — write components once
Components are ordinary Leptos components; they run on both sides:
#[component]
pub fn NotesPage() -> impl IntoView {
let notes = Resource::new(|| (), |_| list_notes()); // calls a server function
view! {
<h1>"Notes"</h1>
<Suspense fallback=|| view! { <p>"Loading…"</p> }>
{move || notes.get().map(|r| match r {
Ok(list) => view! { <ul>{list.into_iter().map(|n| view! { <li>{n.title}</li> }).collect_view()}</ul> }.into_any(),
Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
})}
</Suspense>
}
}
On the server, the Resource runs during rendering and its result is serialised into the HTML. On the client, hydration reads that serialised value
instead of calling the server again, so the first render does not refetch data it already has.
Step 3 — use server functions for data
A server function is defined once and compiled differently on each side: on the server it is the real function; in the Wasm build it becomes an HTTP call to an automatically registered endpoint:
#[server]
pub async fn list_notes() -> Result<Vec<Note>, ServerFnError> {
let pool = expect_context::<sqlx::PgPool>(); // only exists on the server
Ok(sqlx::query_as!(Note, "select id, title from notes order by id desc").fetch_all(&pool).await?)
}
The body, its imports and its dependencies are compiled only with ssr. Arguments and results must be serialisable. This removes most hand-written API
glue, and it keeps database code out of the browser bundle by construction.
Step 4 — stream HTML and hydrate early
Leptos can stream HTML: the shell is sent immediately and each Suspense boundary’s content follows when its data resolves, with out-of-order streaming
filling placeholders as data arrives. Users see the page frame and fast content quickly while slow queries are still running. Configure the route with
SsrMode::OutOfOrder (the default for many templates) or PartiallyBlocked when some content must be in the initial HTML for search engines.
Step 5 — keep the Wasm bundle small
Time to interactive depends on downloading, compiling and running the Wasm bundle. Use a dedicated release profile for it, with size optimisations, and
let cargo-leptos run wasm-opt:
[profile.wasm-release]
inherits = "release"
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
Avoid pulling heavy crates into the hydrate build — date libraries, regex engines and full serde formats add tens of kilobytes each. Leptos supports
splitting the bundle per route (lazy routes and islands), so pages that are mostly static ship little or no Wasm. The “islands” mode is especially
effective for content sites: only components marked #[island] hydrate, and everything else stays server-rendered HTML with no Wasm cost.
Deploying the two halves
A release build produces two artefacts that must be deployed together: the server binary and the site directory containing the Wasm bundle, its
JavaScript glue, CSS and static assets. Serve the site directory from the same server, or from a CDN with long cache lifetimes — cargo-leptos can hash
file names so that new releases never collide with cached old files. The server must know where the site directory is, through the LEPTOS_SITE_ROOT
environment variable or configuration, and must serve .wasm files with Content-Type: application/wasm so streaming compilation works. Because
server functions are HTTP endpoints, a new server and an old cached Wasm bundle can briefly talk to each other during a rollout; keep server function
signatures backward-compatible across one release, or version their paths, so users with an open tab are not broken mid-session. A multi-stage
Dockerfile that builds both halves and copies only the binary and the site directory into a slim runtime image is the usual way to package it.
Debugging hydration mismatches
Hydration assumes the DOM the browser received matches what the client-side components would have rendered. When it does not, Leptos logs a hydration
error, and in the worst cases events attach to the wrong elements. The usual causes are output that differs between server and client: rendering the
current time, a random id, a value from window or localStorage, or data the server fetched that the client recomputes differently. Browsers also
alter some HTML during parsing — a <div> inside a <p>, a missing <tbody> in a table — so invalid nesting in a view! produces a DOM that no longer
matches. Fix the first kind by computing the value on the server and passing it through a resource, or by rendering it only after hydration in an
Effect. Fix the second by writing valid HTML. The development build reports the component and position of the first mismatch, which usually points
straight at the cause; release builds skip the checks, so test hydration in development builds before shipping.
Expected output
cargo leptos build --release produces a server binary and a site directory with app.wasm of about 280 KB (95 KB compressed); the notes page arrives as
complete HTML, paints within the first round trip, and becomes interactive after hydration in about 300 ms on a mid-range phone.
Gotchas
- Server-only crates in the hydrate build. Gate them behind the
ssrfeature and make them optional dependencies. - Hydration mismatches. Server and client rendered different markup. Avoid time, randomness and browser APIs during render.
- Using
windowduring SSR. It does not exist on the server. Access it in effects that run only in the browser. - Huge Wasm bundles. Use the size profile,
wasm-opt, islands or lazy routes. - Breaking server functions mid-rollout. Old cached bundles still call them. Keep signatures compatible for a release.
- Forgetting serialisability. Server function arguments and results must implement
SerializeandDeserialize.
Performance note
On a throttled mobile connection, the SSR version painted content at 0.9 s and became interactive at 1.6 s; a client-side-rendered build of the same app painted nothing until 2.3 s. Switching a content-heavy section to islands cut the Wasm bundle from 280 KB to 120 KB.
Frequently Asked Questions
Do I need a Rust server, or can I deploy to a JavaScript host?
The ssr build is a native binary. Some integrations target WASI or edge platforms, but the common deployment is a container running the server.
How does this compare with Yew or Dioxus? Dioxus also supports full-stack rendering; Yew’s SSR support is more limited. See comparing Yew, Leptos and Dioxus.
Can I use Tailwind or CSS frameworks? Yes — cargo-leptos runs Tailwind and other style tools as part of the build.
Is hydration required? No. Pages can be server-rendered only, with islands for interactive parts, or fully client-side rendered.
How do I handle authentication? Read cookies or headers in server functions on the server side; the Wasm client just calls the functions, and the browser sends cookies automatically.
Can server functions stream results? Yes — Leptos supports streaming responses and websockets for server functions in recent versions.
Related
- Building a UI with Leptos — the client-side basics.
- Sharing validation logic between server and browser — one implementation on both sides.
- Shrinking Rust Wasm with cargo profiles — smaller hydrate bundles.
- Reducing Wasm cold-start latency — faster time to interactive.
← Back to Full-Stack Frameworks with Wasm