writing-skills
writing-skills
Use when creating new skills, editing existing skills, or verifying skills work before deployment
leptosLeptos 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…
Independent GitHub snapshot · Automated classification · Original documentation governs
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:
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().
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:
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()).
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/
// 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.{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.on:click=, class:name=, style:color=, prop:value=, and bind:value=signal for two-way input binding.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.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.
[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:
// 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();
// 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 names 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):
<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>
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).
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.
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:
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.
leptos, leptos_router, leptos_meta and the server integration on the same line and check which version a tutorial targets.view! — when=move || count.get() > 9 does not parse because > closes the tag; write when=move || { count.get() > 9 }.optional, enable them in the ssr feature, and touch them only inside #[server] bodies or #[cfg(feature = "ssr")] items.window, random values or the clock during render causes hydration errors. Do that work in Effect::new.expect_context::<T>() panics when no value of that exact type was provided; use_context::<T>() returns an Option.Resource under <Suspense> streams in during SSR; make it depend on action.version() to refresh after writes.cargo check commands in CI — the ssr and hydrate builds fail independently.