Building a website with Leptos: Rust in the browser that actually works

Published on 2025-06-09

Technologies Used

  • WebAssembly
  • Web Development
  • Rust
  • Leptos
  • Reactive Programming

Project Summary

Why Leptos is the first Rust web framework that actually delivers on performance and developer experience

Building a website with Leptos: Rust in the browser that actually works

I've been experimenting with Leptos for the past few weeks, and I'm genuinely impressed. This is the Rust web framework that finally made me understand why people keep trying to put Rust in the browser—and more importantly, it's the first one where the developer experience doesn't make me want to throw my laptop out the window.

Leptos is a full-stack web framework for Rust that uses fine-grained reactivity and compiles to WebAssembly. If you've used SolidJS, the reactivity model will feel familiar. If you haven't, think React but without the virtual DOM overhead and with compile-time guarantees that your code won't explode at runtime.

I built a small tool with it last week—a reactive data table component that can handle files with 100,000+ rows without breaking a sweat. Here's what I learned.

Getting started without the pain

The biggest surprise with Leptos was how quickly I could get something running. With most Rust web frameworks, you spend the first hour fighting with the toolchain. Not here.

cargo install trunk
cargo install wasm-bindgen-cli
cargo init my-leptos-app
cd my-leptos-app

# Add to Cargo.toml, then:
trunk serve --open

That's it. You get hot reloading, automatic WebAssembly compilation, and a development server. It just works.

For SSR (server-side rendering), you can use cargo-leptos:

cargo install cargo-leptos
cargo leptos new --git leptos-rs/start-axum
cd my-app
cargo leptos watch

The tooling is doing a lot of heavy lifting here. It's handling the dual compilation (your app needs to compile both for the server and for WebAssembly), managing the development server, and even optimizing your WASM bundle size.

The mental model that clicked

Here's the thing about Leptos that made everything click for me: signals are just values that know when they change.

use leptos::*;

#[component]
fn Counter() -> impl IntoView {
    let (count, set_count) = create_signal(0);
    
    view! {
        <div>
            <button on:click=move |_| set_count(count() + 1)>
                "Count: " {count}
            </button>
        </div>
    }
}

That create_signal gives you a getter and a setter. The magic is that Leptos tracks which parts of your UI depend on which signals, and only updates those specific DOM nodes when the signal changes. No virtual DOM diffing, no unnecessary re-renders.

I tested this with a data table component by creating signals for 10,000 table cells and updating just one. DevTools showed exactly one DOM update. That's the kind of performance that makes me excited about a framework.

Server functions that don't suck

The feature that really sold me on Leptos was server functions. You write a function, annotate it with #[server], and Leptos automatically:

  • Creates an API endpoint
  • Generates the client-side code to call it
  • Handles serialization/deserialization
  • Manages loading states

Here's a real example from my blog project:

#[server(LoadCsv, "/api")]
pub async fn load_csv(url: String) -> Result<Vec<Vec<String>>, ServerFnError> {
    // This runs on the server
    let response = reqwest::get(&url).await?;
    let text = response.text().await?;
    
    let mut reader = csv::Reader::from_reader(text.as_bytes());
    let records: Vec<Vec<String>> = reader
        .records()
        .filter_map(|r| r.ok())
        .map(|r| r.iter().map(|s| s.to_string()).collect())
        .collect();
        
    Ok(records)
}

#[component]
fn CsvLoader() -> impl IntoView {
    let load_csv = create_server_action::<LoadCsv>();
    
    view! {
        <ActionForm action=load_csv>
            <input type="text" name="url" placeholder="CSV URL"/>
            <button type="submit">"Load CSV"</button>
        </ActionForm>
        
        <Suspense fallback=|| view! { <p>"Loading..."</p> }>
            {move || {
                load_csv.value().with(|data| match data {
                    Some(Ok(data)) => view! { <CsvTable data=data.clone()/> },
                    Some(Err(e)) => view! { <p>"Error: " {e.to_string()}</p> },
                    None => view! { <p>"Enter a CSV URL to load"</p> },
                })
            }}
        </Suspense>
    }
}

The #[server] macro is doing an enormous amount of work here. It's generating TypeScript-style type safety across the client-server boundary, but at compile time.

The view! macro is actually good

I was skeptical about the view! macro at first. JSX-in-Rust sounded like a terrible idea. But it works remarkably well:

view! {
    <div class="container">
        <h1>"CSV Diff Viewer"</h1>
        <Show
            when=move || !data().is_empty()
            fallback=|| view! { <p>"No data loaded"</p> }
        >
            <For
                each=move || data().into_iter().enumerate()
                key=|(idx, _)| *idx
                let:item
            >
                {let (row_idx, row) = item;
                view! {
                    <tr>
                        <For
                            each=move || row.into_iter().enumerate()
                            key=|(idx, _)| *idx
                            let:cell_item
                        >
                            {let (col_idx, cell) = cell_item;
                            let class = if is_changed(row_idx, col_idx) {
                                "changed"
                            } else {
                                ""
                            };
                            view! {
                                <td class=class>{cell}</td>
                            }}
                        </For>
                    </tr>
                }}
            </For>
        </Show>
    </div>
}

The macro gives you:

  • Compile-time checking of your HTML
  • Automatic escaping
  • Type-safe event handlers
  • Efficient list rendering with the <For> component

That <For> component is particularly clever. It does keyed rendering like React, but the diffing algorithm is written in Rust and runs in WebAssembly, making it surprisingly fast for large lists.

File size: The elephant in the room

Let's address the obvious concern: WebAssembly bundle size. My blog site, with all its dependencies, produces a 390KB WASM file. That's... not small.

But here's what changed my mind: I benchmarked it against a React version I built with similar functionality. The React app with its dependencies was 280KB of JavaScript. Not that different!

Plus, WASM files compress incredibly well. That 390KB becomes 95KB with Brotli compression. And unlike JavaScript, the browser can start compiling the WASM module as soon as it begins downloading.

I used twiggy to analyze what was taking up space:

twiggy top -n 10 pkg/tylerharpool_blog_bg.wasm

Most of the size came from the markdown parsing library. When I switched to a lighter parser, I got the bundle down to 240KB uncompressed.

Debugging actually works now

Previous Rust web frameworks I tried had terrible debugging stories. Leptos is different:

  1. Actual error messages: When something goes wrong, you get a real Rust error with a stack trace, not "unreachable executed" in random WASM bytecode.

  2. Chrome DevTools integration: You can set breakpoints in your Rust code. This still feels like magic to me.

  3. Hot reloading that works: Change your Rust code, save, and the page updates without losing state. It's not quite as fast as JavaScript hot reloading, but it's good enough.

Here's a debugging trick I learned: you can use leptos::logging::log! for debugging:

use leptos::logging::log;

// In your component
log!("Signal value: {:?}", count());

Island architecture without the complexity

Leptos supports "islands" architecture where parts of your page are interactive and parts are static. But unlike other frameworks, you don't have to think about it much:

#[component]
fn App() -> impl IntoView {
    view! {
        <header>
            <h1>"My Site"</h1>  // This becomes static HTML
        </header>
        <main>
            <Counter/>  // This becomes an interactive island
            <p>"Some static content"</p>  // Static HTML
            <CsvLoader/>  // Another island
        </main>
    }
}

Leptos figures out which parts need to be interactive and only ships the JavaScript (well, WebAssembly) for those parts.

Performance that actually matters

I threw some ridiculous tests at my data table component:

  • 100,000 row data file: Loaded and rendered in 1.2 seconds
  • Updating 50,000 rows: 890ms
  • Scrolling performance: Constant 60fps, even with every cell being a signal

For comparison, I tried the same operations with a React + JavaScript implementation:

  • 100,000 rows: 4.3 seconds and noticeable lag
  • Batch updates: 2.1 seconds
  • Scrolling: Dropped to 45fps with frequent stutters

The difference is that Leptos's fine-grained reactivity means it's only updating what actually changed, and the update logic is compiled Rust running at near-native speed.

When I'd actually use this

After building a real app with Leptos, here's where I think it shines:

Perfect for:

  • Data-heavy dashboards
  • Real-time collaborative tools
  • Anything where you're fighting React's performance
  • Teams that are already using Rust on the backend
  • Apps where correctness really matters (financial tools, medical data)

The rough edges

Not great for:

  • Simple marketing sites (the WASM overhead isn't worth it)
  • Projects where you need a huge ecosystem of npm packages
  • Teams without Rust experience

The rough edges

Let's be honest about what's not perfect:

  1. Compile times: My Lexodus project takes 45 seconds for a clean build. Incremental builds are 3-4 seconds, which is tolerable but not great.

  2. Error messages: While better than other Rust web frameworks, some macro errors are still cryptic. I spent 20 minutes debugging a missing comma in a view! macro.

  3. Ecosystem: You're not getting Material UI or Chakra. You'll be building your own components or using basic CSS frameworks.

  4. Learning curve: If you don't know Rust, add 2-3 months to your timeline.

Try this yourself

Here's a minimal starting point that shows off what makes Leptos interesting:

use leptos::*;

#[component]
fn App() -> impl IntoView {
    let (rows, set_rows) = create_signal(vec![
        vec!["Name", "Score", "Grade"],
        vec!["Alice", "95", "A"],
        vec!["Bob", "87", "B"],
    ]);
    
    let add_row = move |_| {
        set_rows.update(|r| {
            r.push(vec!["New", "0", "F"]);
        });
    };
    
    view! {
        <div>
            <button on:click=add_row>"Add Row"</button>
            <table>
                <For
                    each=move || rows().into_iter().enumerate()
                    key=|(i, _)| *i
                    let:row
                >
                    <tr>
                        <For
                            each=move || row.1.into_iter()
                            key=|cell| cell.clone()
                            let:cell
                        >
                            <td>{cell}</td>
                        </For>
                    </tr>
                </For>
            </table>
        </div>
    }
}

fn main() {
    leptos::mount_to_body(App);
}

Save that as src/main.rs, add this to your Cargo.toml:

[dependencies]
leptos = { version = "0.6", features = ["csr"] }

[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1

And you've got a working Leptos app.

The bottom line

Leptos is the first Rust web framework that I'd actually use for a real project. It's not trying to be React-but-in-Rust. It's taking the good ideas from JavaScript frameworks and implementing them in a way that makes sense for Rust.

The performance benefits are real, the developer experience is finally good enough, and the type safety across the full stack is genuinely useful.

If you're already using Rust and want to build web apps, Leptos is a no-brainer. If you're JavaScript-only but hitting performance walls, it's worth a serious look. Just be prepared for a learning curve and a smaller ecosystem.

I've been dogfooding Leptos in several projects. You can check out Lexodus, a legal document management system I'm building with Leptos, and this very blog (source code here) is actually powered by Leptos! Both projects showcase different aspects of what makes Leptos powerful—from complex state management to static site generation.