How to Build a Frontend Application with Leptos

Web
You build a Leptos frontend by defining your UI components in Rust using the `leptos` macro system, then compiling the project to WebAssembly (WASM) via `cargo leptos` or `trunk` to run in the browser.

Building a frontend with Leptos

You've spent the afternoon wrestling with node_modules, fighting a build tool that breaks every time you update a dependency, and debugging a type error that only shows up in the browser console. You write Rust for the backend because it catches mistakes at compile time. You want that same guarantee for the UI. You want to write components in Rust, compile them to WebAssembly, and run them in the browser without sacrificing the developer experience. Leptos gives you exactly that. It's a full-stack web framework that lets you build reactive UIs with Rust, compiling to WASM for the client and rendering on the server when you need it.

Fine-grained reactivity explained

Leptos uses a fine-grained reactive system. In many JavaScript frameworks, a state change triggers a re-render of the component tree, and the framework figures out what changed in the DOM. Leptos works differently. It tracks exactly which parts of the UI depend on which pieces of state. When state changes, only the specific DOM nodes that depend on that state update.

Think of it like a spreadsheet. You change a cell, and only the formulas that reference that cell recalculate. The rest of the sheet stays untouched. Leptos builds a dependency graph at runtime. When you read a signal inside a view, Leptos records that dependency. When you update the signal, Leptos traverses the graph and updates only the affected nodes. This makes Leptos incredibly fast, even with complex state, because it avoids unnecessary work. You're not paying the cost of virtual DOM diffing on every keystroke.

Trust the dependency graph. It does the heavy lifting so you don't have to.

Your first component

Start by scaffolding a project. The cargo leptos tool handles the boilerplate and sets up the build configuration.

cargo leptos new --template counter my-app
cd my-app
cargo leptos watch

This command creates a project structure, installs dependencies, and starts a development server. The server compiles Rust to WASM on the fly and serves the application. Open src/app.rs to see the root component.

use leptos::*;

/// The root application component.
#[component]
pub fn App() -> impl IntoView {
    // Create a reactive signal with an initial value of 0.
    // `count` is the getter, `set_count` is the setter.
    let (count, set_count) = create_signal(0);

    view! {
        <div>
            <p>"Current count: " {count}</p>
            // The closure captures `set_count` to update the signal.
            <button on:click=move |_| set_count.update(|n| *n += 1)>
                "Increment"
            </button>
        </div>
    }
}

The view! macro transforms Rust expressions into HTML-like syntax. It's not a string template. The compiler checks attribute names and types. If you misspell an attribute, you get a compile error, not a silent failure in the browser. The create_signal function returns a tuple. The first element is a getter, the second is a setter. This separation is deliberate. It makes it clear which values are reactive and which are static.

The community convention is to use create_rw_signal for mutable access in newer Leptos versions. It combines the getter and setter into a single type, reducing boilerplate. Stick with create_signal if you want explicit separation, or switch to create_rw_signal for brevity. Both work correctly.

Let the compiler catch your typos. It's faster than debugging the browser console.

How the build and runtime work

When you run cargo leptos watch, the toolchain compiles your Rust code to WebAssembly. WASM runs in the browser sandbox, giving you near-native performance. Leptos generates a dependency graph during initialization. When set_count is called, Leptos traverses the graph and updates only the text node inside the <p> tag. The button and the surrounding <div> are never touched.

For production builds, run cargo leptos build. This generates optimized WASM and HTML files. The build process also creates a server binary if you enable SSR. The server renders the initial HTML, sends it to the client, and then the WASM hydrates the page. Hydration means the client-side code takes over the DOM without re-rendering everything. The user sees content immediately, and interactivity kicks in as soon as the WASM loads.

Fine-grained reactivity isn't just a buzzword. It's the reason your app stays smooth under load.

A realistic filterable list

Real applications often manage lists of data. Leptos provides the ForEach component to render lists efficiently. It tracks items by a key, so it can update, insert, or remove DOM nodes without re-rendering the whole list.

use leptos::*;

/// A list of items that can be filtered reactively.
#[component]
pub fn FilteredList() -> impl IntoView {
    // Reactive list of items.
    let (items, set_items) = create_signal(vec!["Rust", "Leptos", "WASM"]);
    // Reactive filter text.
    let (filter, set_filter) = create_signal(String::new());

    // Memoized computation: only re-runs when `items` or `filter` changes.
    let filtered_items = move || {
        let filter_text = filter.get();
        items.get().into_iter()
            .filter(|item| item.contains(&filter_text))
            .collect::<Vec<_>>()
    };

    view! {
        <div class="list-container">
            <input
                type="text"
                placeholder="Filter..."
                // Update the filter signal on input events.
                on:input=move |e| set_filter.set(event_target_value(&e))
            />
            <ul>
                // `ForEach` updates only the changed list items.
                ForEach {
                    // The signal providing the list of items.
                    move || filtered_items.get(),
                    // The render function for each item.
                    move |item| view! { <li>{item}</li> }.into_view(),
                    // The key function for tracking items.
                    move |item| item.to_string(),
                }
            </ul>
        </div>
    }
}

This example shows a filterable list. The filtered_items signal computes the filtered list only when the source data or the filter text changes. The ForEach component efficiently updates the list DOM nodes. When you type in the input, only the list items update. The input field itself doesn't re-render. This is the power of fine-grained reactivity. You compose signals and effects to build complex UIs without manual DOM manipulation.

Build with signals, not state mutations. The reactivity system handles the rest.

Common pitfalls and compiler errors

Leptos relies heavily on Rust's ownership model. You'll run into borrow checker errors if you try to hold references across async boundaries or closures. For example, if you try to capture a reference in a closure that outlives the reference, the compiler rejects you with E0597 (borrowed value does not live long enough). The fix is usually to clone the data or use Rc to share ownership.

Another common issue is forgetting that signals are getters. If you write {count} in the view, it works because count implements Display via IntoView. If you write {count.get()}, it also works, but count is preferred for readability. If you try to use a non-reactive value where a signal is expected, you'll get a trait bound error like E0277. The compiler tells you the type doesn't implement IntoView. Check your imports and ensure you're using the right types.

When working with async code, use create_resource to fetch data. It handles loading states and errors automatically. Don't try to manage async state manually with signals unless you have a specific reason. create_resource is optimized for this pattern.

Read the compiler error. It usually points directly to the fix.

When to use Leptos

Use Leptos when you want to write your entire frontend in Rust and compile to WASM for type safety and performance. Use Leptos when you need fine-grained reactivity and want to avoid the overhead of virtual DOM diffing in large applications. Use Leptos when you are building a full-stack application and want to share code between the server and client, leveraging SSR for initial load performance. Reach for a JavaScript framework when you need to integrate with a vast ecosystem of existing JS libraries that have no WASM bindings. Reach for a static site generator when your content is mostly static and you don't need complex client-side interactivity.

Pick the tool that matches your complexity. Leptos shines when you need Rust's guarantees in the browser.

Where to go next