Wisp
A Fast, Fun Web Framework for Rust
cargo install --git https://wisp.ar0.eu wisp-cli
A Page Is One File
The form writes its own inputs and errors, and a bad value is a 422 that keeps what was typed. The model in src/db.rs is in every route file with no use lines.
---
#[action]
fn add(todo: Todo) {
TODOS.add(todo);
}
#[action]
fn remove(id: u64) {
TODOS.remove(id);
}
---
<title>Todos ({TODOS.len()})</title>
<form action="?/add" fields><button>Add</button></form>
{#each TODOS.all() as todo}
<p>{todo.text} <button action="?/remove&id={todo.id}">Remove</button></p>
{/each}
#[model]
pub struct Todo {
#[validate(len = 1..=100)]
text: String,
}
pub static TODOS: Table<Todo> = Table::saved();
Todos (2)
- Buy milk
- Write the docs
Nothing Extra on the Hot Path
A route pays only for the features it uses, and a change that touches the request path is checked by an instructions-per-request A/B before it lands. Wisp's first rule is that speed is never traded away for convenience.
Measured, Not Claimed
TechEmpower's plaintext and JSON tests, run with their own load scripts against their reference sources of each framework, on one 4-vCPU VM with the server pinned to 2 cores. Medians of 3 runs of 15 seconds each.
| Framework | Built On | Relative speed | Req/s |
|---|---|---|---|
| Wisp | Rust | 1,129,577 | |
| Fastify | Node.js | 51,713 | |
| Express | Node.js | 40,421 | |
| SvelteKit | Svelte | 10,929 | |
| Next.js | React | Out of memory |
| Framework | Built On | Relative speed | Req/s |
|---|---|---|---|
| Wisp | Rust | 96,089 | |
| Fastify | Node.js | 21,965 | |
| Express | Node.js | 14,746 | |
| SvelteKit | Svelte | 10,378 | |
| Next.js | React | 1,524 |
Wisp is first on plaintext at 256, 1,024 and 4,096 connections, and first on JSON at 3 of 6 levels. The tables show the stacks most people know; the full results also include two other Rust servers, Actix Web and Axum, which are close to Wisp on JSON, and Actix Web is ahead at 16 and 256 connections. Next.js ran out of memory on the pipelined plaintext test, so it has no number there. At 16,384 connections Wisp's default limit of 10,000 refuses the overflow, and most servers collapse there too. This is not an official TechEmpower result: the VM is shared, and the gaps between the Rust servers are about the size of its noise.
Make the Same App with About Half the Tokens
AI writes most code now, and every token it reads and writes costs time and money. Wisp is built so an app costs the fewest: conventions instead of config, types the compiler infers, and forms that write themselves. The whole reference is one file, llms.txt and AGENTS.md, and wisp mcp serves it to coding agents.
claude mcp add wisp -- wisp mcp
---
fn default(#[validate(len = 1..=50)] name: String, email: Email) {
eprintln!("{name} <{email}>");
redirect("/")
}
---
<title>Contact</title>
<form fields><button>Send</button></form>
import { fail, redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const form = Object.fromEntries(await request.formData());
const errors = {};
if (!form.name || form.name.length > 50) errors.name = 'must have 1 to 50 characters';
if (!/^\S+@\S+\.\S+$/.test(form.email)) errors.email = 'must be an email address';
if (Object.keys(errors).length) return fail(422, { ...form, errors });
console.log(`${form.name} <${form.email}>`);
redirect(303, '/');
}
};
<script>
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<svelte:head><title>Contact</title></svelte:head>
<form method="POST" use:enhance>
<label>Name <input name="name" required minlength="1" maxlength="50" value={form?.name ?? ''} />
{#if form?.errors?.name}<small class="problem">{form.errors.name}</small>{/if}</label>
<label>Email <input name="email" type="email" required value={form?.email ?? ''} />
{#if form?.errors?.email}<small class="problem">{form.errors.email}</small>{/if}</label>
<button>Send</button>
</form>
The same five features (a list page, a contact form, a JSON endpoint, a layout and a live search) written as a complete app in each stack:
| Stack | Relative size | Tokens | Files |
|---|---|---|---|
| Wisp | 464 | 6 | |
| Nuxt (Vue) | 872 | 8 | |
| SvelteKit | 957 | 9 | |
| Next.js (React) | 971 | 8 | |
| Express (Node.js) | 1,156 | 7 | |
| React (Vite + Express) | 1,382 | 8 |
A bigger app, with sign up and in, a posts table, uploads, live refresh and a component, is 995 tokens in Wisp, 3,473 in SvelteKit and 3,331 in Next.js. The numbers come from cargo run -p wisp-tokens, which counts every hand-written file and its path with a byte-pair style estimate; the Tokens page has the method and the apps.
Forms That Work Without JavaScript
A form posts to an action. A bad value is a 422 that shows each problem beside its input and keeps what was typed. With JavaScript on, the page morphs instead of reloading.
---
#[action]
fn signup(email: Email, #[validate(min_len = 8)] password: Password) {
cx.signup(User { email, password }).await?;
redirect("/me")
}
---
<form action="?/signup" fields>
<button>Sign up</button>
</form>
Reactivity in the Same File
The block is Rust that runs for each request, name is drawn on the server, and the count is JavaScript state in the browser. Turn JavaScript off and the server's HTML still works.
---
let name = cx.query_or("name", "world".to_string());
---
<h1>Hello, {name}!</h1>
<button on:click="count++">Clicked {:count} times</button>
<script>
let count = $state(0)
</script>
One Binary, on Any Host
Templates compile to plain Rust, and the whole app, styles and static files included, becomes one small binary. Every fast path is proven at startup and falls back, and nothing after startup panics. The same app builds as a container, static HTML, or for an edge or serverless host. This site is one too: server-rendered pages that work with JavaScript off, down to the demo and the search form.
wisp build
wisp build --static
wisp build --docker
wisp build --target cloudflare
wisp deploy init cloudflare