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-unknown target, and cargo-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.

First request to a Leptos SSR app The browser requests a page. The server runs the components natively and streams HTML, including serialised resource data. The browser paints the HTML immediately, then downloads the Wasm bundle, which hydrates the existing DOM and makes it interactive. browser Leptos server Wasm bundle GET /notes streamed HTML + serialised data first paint — content visible load app.wasm + glue hydrate(): attach listeners

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.

What each build contains Components and shared types compile into both builds. Server function bodies, database access and secrets exist only in the native server build. Hydration logic and browser event handling exist only in the Wasm build, where server functions become HTTP calls. code server build (ssr) Wasm build (hydrate) components render to HTML hydrate the DOM server function body real implementation replaced by HTTP call database driver included excluded event handlers not run attached on hydration shared types included included

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 ssr feature and make them optional dependencies.
  • Hydration mismatches. Server and client rendered different markup. Avoid time, randomness and browser APIs during render.
  • Using window during 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 Serialize and Deserialize.

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.

First contentful paint, SSR versus client-side rendering Seconds to first contentful paint on a throttled mobile connection for the same Leptos app rendered on the server with hydration and rendered entirely in the browser. seconds to first contentful paint SSR + hydration 0.9 s client-side rendering only 2.3 s

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.

← Back to Full-Stack Frameworks with Wasm