Skip to content
Back to skills

Leptos

ASecurity

Leptos is a Rust web framework that builds reactive user interfaces compiled to WebAssembly, either as a client-side app or as a full-stack app with server-side rendering, hydration and server functions on Axum or Actix. Use when a user asks to "build a web app in Rust", "start a Leptos project", "add a server function", "use signals and resources in Leptos", "set up cargo-leptos", "fix Leptos code that no longer compiles after 0.7", or "deploy a Leptos SSR app". Covers Leptos 0.8: signals, t...

  • 142 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added May 29, 2026
developmentjavascriptrustgojavashellbashsqlreactnodegit

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add TerminalSkills/skills --skill leptos --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Leptos?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Leptos
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/terminalskills-leptos/badge)](https://www.skillsdirectory.com/skills/terminalskills-leptos)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: leptos
description: >-
  Leptos is a Rust web framework that builds reactive user interfaces compiled
  to WebAssembly, either as a client-side app or as a full-stack app with
  server-side rendering, hydration and server functions on Axum or Actix. Use
  when a user asks to "build a web app in Rust", "start a Leptos project",
  "add a server function", "use signals and resources in Leptos", "set up
  cargo-leptos", "fix Leptos code that no longer compiles after 0.7", or
  "deploy a Leptos SSR app". Covers Leptos 0.8: signals, the view macro,
  resources and Suspense, server functions and actions, routing, islands,
  Trunk and cargo-leptos.
license: Apache-2.0
compatibility: "Rust stable with the wasm32-unknown-unknown target. Client-side apps need Trunk; full-stack apps need cargo-leptos and Axum 0.8 or Actix. Compiled against leptos 0.8.21 on Rust 1.99."
metadata:
  author: terminal-skills
  version: 1.1.0
  category: development
  repository: https://github.com/leptos-rs/leptos
  tags:
    - rust
    - wasm
    - full-stack
    - reactive
    - ssr
---

# Leptos — Full-Stack Rust Web Framework

## Overview

Leptos renders HTML from Rust components written with the `view!` macro and updates the page through fine-grained signals: when a signal changes, only the DOM nodes that read it are touched. It runs in two modes:

- **Client-side rendering (CSR)** — the app compiles to WebAssembly and is served as static files by Trunk. Fast builds, any backend.
- **Server-side rendering (SSR) with hydration** — one crate compiles twice: a native server binary (feature `ssr`) that renders HTML, and a WebAssembly bundle (feature `hydrate`) that makes it interactive. `cargo-leptos` coordinates both builds, and server functions let client code call server code directly.

Leptos 0.7 rewrote the API, and 0.8 keeps it. Code from older tutorials (`use leptos::*`, `create_signal`, `create_resource`, `.into_view()` on every branch) fails or warns: the current forms are `use leptos::prelude::*`, `signal()`, `Resource::new()`, `.into_any()`.

## Instructions

### 1. Set up the toolchain

Rust comes from rustup (use your distribution's `rustup` package, or download `rustup-init` and check it against the `.sha256` published next to it). Then:

```bash
rustup target add wasm32-unknown-unknown
cargo install --locked trunk            # CSR apps
cargo install --locked cargo-leptos     # full-stack apps
```

Stable Rust is enough. The `nightly` feature of `leptos` only adds function-call syntax for signals (`count()` instead of `count.get()`).

### 2. Client-side app with Trunk

```bash
cargo init reading-timer && cd reading-timer
cargo add leptos --features=csr
cargo add console_error_panic_hook
printf '<!DOCTYPE html>\n<html>\n  <head></head>\n  <body></body>\n</html>\n' > index.html
trunk serve --open                      # rebuilds and reloads on save
trunk build --release                   # static site in dist/
```

```rust
// src/main.rs
use leptos::prelude::*;

#[component]
fn Counter(#[prop(default = 0)] initial: i32, #[prop(into)] label: String) -> impl IntoView {
    let (count, set_count) = signal(initial);
    let doubled = Memo::new(move |_| count.get() * 2);
    Effect::new(move |_| leptos::logging::log!("count is {}", count.get()));

    view! {
        <button on:click=move |_| set_count.update(|n| *n -= 1)>"-"</button>
        <span class:negative=move || count.get() < 0>{label} ": " {count} " (doubled: " {doubled} ")"</span>
        <button on:click=move |_| *set_count.write() += 1>"+"</button>
        <Show when=move || { count.get() > 9 } fallback=|| view! { <p>"Keep going"</p> }>
            <p>"Double digits"</p>
        </Show>
    }
}

fn main() {
    console_error_panic_hook::set_once();
    leptos::mount::mount_to_body(|| view! { <Counter initial=3 label="Clicks"/> });
}
```

What to know about signals and the view macro:

- `signal(v)` returns a `(ReadSignal, WriteSignal)` pair; `RwSignal::new(v)` is one handle for both. Read with `.get()` (clones) or `.read()` (borrows); write with `.set(v)`, `.update(|v| ...)` or `*sig.write() = v`. All are `Copy`, so `move` closures can capture them freely.
- A value is reactive in the view only if it is a signal or a closure: `{count}` and `{move || count.get() * 2}` update, `{count.get()}` is evaluated once.
- `Memo::new` caches a derived value; `Effect::new` runs side effects in the browser after render and never on the server.
- Text nodes are string literals in quotes. Attributes: `on:click=`, `class:name=`, `style:color=`, `prop:value=`, and `bind:value=signal` for two-way input binding.
- Branches of an `if`/`match` inside `view!` must have one type: end each branch with `.into_any()` or use `<Show>`. Lists use `<For each=... key=... children=.../>` with a stable key.

### 3. Full-stack project with cargo-leptos

```bash
cargo leptos new --git https://github.com/leptos-rs/start-axum --name reading-list   # or .../start-actix
cd reading-list
cargo leptos watch                      # http://127.0.0.1:3000, rebuilds server and client
cargo leptos build --release            # target/release/reading-list + target/site/
```

`cargo leptos new` is a wizard: it asks "Use nightly features?" (answer No) and stops with `IO error: not a terminal` in a script or agent session. There, run `cargo generate --git https://github.com/leptos-rs/start-axum --name reading-list -d nightly=No` instead (`cargo install --locked cargo-generate`). The template's `Cargo.toml` wires the two builds. Keep this shape when adding dependencies: server-only crates are `optional` and listed under `ssr`.

```toml
[dependencies]
leptos = { version = "0.8" }
leptos_router = { version = "0.8" }
leptos_meta = { version = "0.8" }
serde = { version = "1", features = ["derive"] }                                        # added
axum = { version = "0.8", optional = true }
leptos_axum = { version = "0.8", optional = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"], optional = true }   # added
console_error_panic_hook = { version = "0.1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }

[features]
hydrate = ["leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen"]
ssr = ["dep:axum", "dep:tokio", "dep:leptos_axum", "dep:sqlx", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr"]
```

`[package.metadata.leptos]` in the same file sets `site-root`, `site-addr` (default `127.0.0.1:3000`), `bin-features = ["ssr"]` and `lib-features = ["hydrate"]`.

`src/lib.rs` exports `hydrate()`, which calls `leptos::mount::hydrate_body(App)`; `src/app.rs` holds `shell()` (the HTML document with `<HydrationScripts options/>`) and the `App` component; `src/main.rs` is the server. To give server functions a database pool, pass it as context when registering routes:

```rust
// src/main.rs (inside #[cfg(feature = "ssr")] #[tokio::main] async fn main)
use axum::Router;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use reading_list::app::{shell, App};

let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is not set");
let pool = sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&database_url).await.expect("database");

let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
let routes = generate_route_list(App);

let app = Router::new()
    .leptos_routes_with_context(
        &leptos_options,
        routes,
        move || provide_context(pool.clone()),          // visible to components and server functions
        {
            let leptos_options = leptos_options.clone();
            move || shell(leptos_options.clone())
        },
    )
    .fallback(leptos_axum::file_and_error_handler(shell))
    .with_state(leptos_options);

let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service()).await.unwrap();
```

### 4. Server functions, resources and actions

```rust
// src/app.rs
use leptos::prelude::*;
use leptos_router::{components::{Route, Router, Routes, A}, hooks::use_params_map, path};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Book {
    pub id: i64,
    pub title: String,
    pub author: String,
}

#[server]
pub async fn list_books() -> Result<Vec<Book>, ServerFnError> {
    let pool = expect_context::<sqlx::PgPool>();
    let books = sqlx::query_as::<_, Book>("SELECT id, title, author FROM books ORDER BY id DESC LIMIT 50")
        .fetch_all(&pool)
        .await?;
    Ok(books)
}

#[server]
pub async fn add_book(title: String, author: String) -> Result<(), ServerFnError> {
    if title.trim().is_empty() {
        return Err(ServerFnError::new("title is required"));
    }
    let pool = expect_context::<sqlx::PgPool>();
    sqlx::query("INSERT INTO books (title, author) VALUES ($1, $2)")
        .bind(title)
        .bind(author)
        .execute(&pool)
        .await?;
    Ok(())
}

#[component]
fn BookList() -> impl IntoView {
    let add = ServerAction::<AddBook>::new();
    // Refetches whenever the action finishes
    let books = Resource::new(move || add.version().get(), |_| list_books());

    view! {
        <ActionForm action=add>
            <input type="text" name="title" placeholder="Title"/>
            <input type="text" name="author" placeholder="Author"/>
            <button type="submit">"Add"</button>
        </ActionForm>
        <Suspense fallback=|| view! { <p>"Loading..."</p> }>
            <ErrorBoundary fallback=|errors| view! { <p class="error">{move || format!("{:?}", errors.get())}</p> }>
                <ul>
                    {move || Suspend::new(async move {
                        books.await.map(|books| {
                            books
                                .into_iter()
                                .map(|b| view! { <li><A href=format!("/books/{}", b.id)>{b.title}</A> " by " {b.author}</li> })
                                .collect_view()
                        })
                    })}
                </ul>
            </ErrorBoundary>
        </Suspense>
    }
}

#[component]
fn BookPage() -> impl IntoView {
    let params = use_params_map();
    let id = move || params.read().get("id").unwrap_or_default();
    view! { <h1>"Book #" {id}</h1> }
}
```

`#[server]` keeps the function body out of the WebAssembly build and generates an HTTP endpoint plus a client stub with the same signature. The macro also creates a struct named after the function in PascalCase (`AddBook`), which `ServerAction` and `<ActionForm>` use; the form's input `name`s must match the argument names, and it works without JavaScript. Arguments and return values must implement `Serialize` and `Deserialize`. Routes go inside `App` (`HomePage` is the template's start page):

```rust
<Router>
    <Routes fallback=|| "Page not found.">
        <Route path=path!("/") view=HomePage/>
        <Route path=path!("/books") view=BookList/>
        <Route path=path!("/books/:id") view=BookPage/>
    </Routes>
</Router>
```

### 5. Islands and release builds

Islands mode ships no WebAssembly for static components. Enable the `islands` feature on `leptos`, mark interactive components with `#[island]` instead of `#[component]`, call `leptos::mount::hydrate_islands()` in `hydrate()`, and write `<HydrationScripts options islands=true/>` in the shell.

The templates already build the WebAssembly bundle with a size-optimised profile (`[profile.wasm-release]` with `opt-level = "z"`, LTO and one codegen unit, selected by `lib-profile-release = "wasm-release"`); keep it when editing `Cargo.toml`.

To deploy, copy the binary from `target/release/` and the `target/site/` directory to the server and set `LEPTOS_SITE_ROOT=site` and `LEPTOS_SITE_ADDR=0.0.0.0:8080` (the address defaults to `127.0.0.1:3000`, which is unreachable from outside a container).

## Examples

### Example 1: A counter page without a backend

**User request:**

```
I want to try Leptos. Make me a small page with a counter that I can open in the browser, no server.
```

The agent uses the commands and `src/main.rs` from section 2, then runs `trunk serve --open`. Trunk prints the address it serves (`http://127.0.0.1:8080/` by default) and opens the page: `Clicks: 3 (doubled: 6)` with two buttons. Pressing "+" seven times shows `Clicks: 10 (doubled: 20)` and replaces "Keep going" with "Double digits"; the browser console logs `count is 10`. `trunk build --release` writes `index.html`, a `.js` loader and a `.wasm` file to `dist/` for any static host.

### Example 2: Add a database-backed page to a full-stack app

**User request:**

```
My start-axum project is called reading-list. Add a /books page that lists rows from Postgres and has a form to add one.
```

The agent adds `serde` and optional `sqlx` to `Cargo.toml` as in section 3, creates the pool in `main.rs` and passes it through `leptos_routes_with_context`, puts the `Book` struct, the two server functions and `BookList` from section 4 into `src/app.rs`, registers the `/books` routes, and verifies both halves compile:

```bash
cargo check --features ssr
cargo check --lib --features hydrate --target wasm32-unknown-unknown
DATABASE_URL="$READING_LIST_DATABASE_URL" cargo leptos watch
```

Both checks end with `Finished`. At `http://127.0.0.1:3000/books` the list is rendered into the HTML on the server; submitting the form calls `add_book`, the action's version changes, and the resource refetches the list without a page reload.

## Guidelines

1. **Pin the minor version** — 0.6, 0.7/0.8 and the 0.9 beta are not source-compatible; keep `leptos`, `leptos_router`, `leptos_meta` and the server integration on the same line and check which version a tutorial targets.
2. **Wrap comparisons in braces inside `view!`** — `when=move || count.get() > 9` does not parse because `>` closes the tag; write `when=move || { count.get() > 9 }`.
3. **Server-only code must not reach the client build** — make such dependencies `optional`, enable them in the `ssr` feature, and touch them only inside `#[server]` bodies or `#[cfg(feature = "ssr")]` items.
4. **Server functions are public HTTP endpoints** — validate input and check authentication inside every one; the client stub is not the only caller.
5. **Hydration needs identical output** — the server and the browser must render the same HTML; reading `window`, random values or the clock during render causes hydration errors. Do that work in `Effect::new`.
6. **Context is typed** — `expect_context::<T>()` panics when no value of that exact type was provided; `use_context::<T>()` returns an `Option`.
7. **Load data with resources, mutate with actions** — a `Resource` under `<Suspense>` streams in during SSR; make it depend on `action.version()` to refresh after writes.
8. **Run both `cargo check` commands in CI** — the `ssr` and `hydrate` builds fail independently.
9. **Compile times are the cost** — a full-stack rebuild compiles two targets; use CSR with Trunk when no server rendering is needed.

Files in this skill

  • SKILL.md6 KB
  • _scores.json1.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.