Wisp is in pre-release, self-hosted since GitHub suspended us. Install it, or see the changelog.

Wisp

Tutorial: Guestbook

You will build a guestbook. Visitors sign it with a name and a message, and notes survive restarts. You need to read Rust, not know Wisp.

Set Up

You need Rust 1.89 or later.

cargo install --git https://wisp.ar0.eu/ --tag v1.0.0-pre.3 wisp-web
wisp new guestbook --template minimal
cd guestbook
wisp dev

wisp new asks a few questions (press Enter for the defaults or add -y). Open http://127.0.0.1:3000 and leave wisp dev running. The app starts with these files:

src/main.rs                 wisp::main!();
src/app.html                the page shell
src/app.css                 styles
src/routes/+page.wisp       the home page
src/routes/+layout.wisp     wraps every page
src/routes/+error.wisp      the error page

The Data and the Form

A folder under src/routes is a URL, and its +page.wisp is the page. This file is /. Replace src/routes/+page.wisp:

---
#[model]
struct Entry {
    #[validate(len = 1..=40)]
    name: String,
    #[validate(len = 1..=200)]
    message: String,
}
static ENTRIES: Table<Entry> = Table::saved();

#[action]
fn sign(entry: Entry) {
    ENTRIES.add(entry)?;
    redirect("/")
}
---

<title>Our Guestbook</title>
<meta name="description" content="Leave a note for the next visitor of this small guestbook.">

<h1>Guestbook</h1>
<p>Leave a note for the next visitor.</p>

<form action="?/sign" fields>
  <button>Sign</button>
</form>

Table::saved() keeps rows in a log file under WISP_DATA (.wisp/data in dev, data in a release build), so they survive a restart. Table::new() keeps rows in memory. The <title>, description and <h1> are needed because a new app treats warnings as errors (seo-* in wisp check).

fields writes a labelled input for each parameter of sign, and you add the button. redirect("/") answers with a 303 so a reload does not post again. Submit with the name empty: you get a 422 with the error beside the input and your message kept. This works with JavaScript off.

Show the Notes

Example: Forms That Feel Instant (a guestbook) shows its code and downloads as a project.

Statements in the block run on each request, and the markup reads their names:

---
// Entry and ENTRIES as before
#[action]
fn sign(entry: Entry) {
    ENTRIES.add(entry)?;
    redirect("/")
}

let entries = ENTRIES.all();
---

<title>Guestbook (${entries.len()})</title>

<h1>Guestbook</h1>

<form action="?/sign" fields>
  <button>Sign</button>
</form>

{#each entries.iter().rev() as entry}
  <article>
    <h2>${entry.name}</h2>
    <p>${entry.message}</p>
  </article>
{:else}
  <p>No notes yet. Be the first.</p>
{/each}

{:else} runs when the list is empty. Restart wisp dev and your note is still there.

Make a Component

Create src/components/Note.wisp:

{@props name, message}
<article class="note">
  <h2>${name}</h2>
  <p>${message}</p>
</article>

<style>
  .note {
    border-left: 3px solid var(--accent, #7c5cff);
    padding-left: 1rem;
    margin: 1rem 0;
  }
</style>

{@props} lists the props (untyped means &str), and <style> applies to this file only. Use it in place of the <article>:

{#each entries.iter().rev() as entry}
  <Note name=${entry.name.as_str()} message=${entry.message.as_str()} />
{:else}
  <p>No notes yet. Be the first.</p>
{/each}

Add State in the Browser

A handler that toggles an undeclared name starts it at false. Add a like button to the component:

{@props name, message}
<article class="note">
  <h2>${name}</h2>
  <p>${message}</p>
  <button on:click="liked = !liked">{:liked ? "Liked" : "Like"}</button>
</article>

<style>
  .note {
    border-left: 3px solid var(--accent, #7c5cff);
    padding-left: 1rem;
    margin: 1rem 0;
  }
</style>

{:liked ? "Liked" : "Like"} and the value of on:click are JavaScript, so a click redraws only that button. A reload resets it. To keep a like, send it to the server with an action or a #[remote] function (Browser code).

Check and Test

wisp check
wisp fmt

wisp check reports a bad route, a missing prop or an accessibility problem, and wisp fmt formats .wisp files. Add a test in src/tests.rs:

use wisp::test::{client, fresh};

#[test]
fn signing_the_guestbook() {
    fresh();
    let mut app = client::<crate::App>();

    let bad = app.post_form("/?/sign", &[("name", "Ada"), ("message", "")]);
    assert_eq!(bad.status, 422);

    let ok = app.post_form("/?/sign", &[("name", "Ada"), ("message", "Hello")]);
    assert_eq!(ok.status, 303);
    assert!(app.get("/").text().contains("Hello"));
}

Add the module to src/main.rs:

wisp::main!();

#[cfg(test)]
mod tests;

wisp test runs the app in process, with no server or port. fresh() empties every table first.

Build and Deploy

wisp build

This writes one binary, target/release/guestbook. Copy it to a server and run it. It listens on $HOST:$PORT (0.0.0.0:3000 by default) and writes entries.log into data. Keep that folder across deploys.

You haveRun
A VPS or serverwisp build, then wisp service install
A container hostwisp deploy init docker
Cloudflare, Deno, Vercel, Netlify or Lambdawisp build --target <host>

A guestbook has a form, so it needs a server. wisp build --static is for pages with no actions. On a host with no disk, use a database store. See Deploying and Edge and serverless targets.

Challenges

Try these first, then open the solution.

Delete a Note

Add a Delete button under each note that deletes it.

Show Solution

A button with an action attribute outside a form is a one-button form, and the action receives the extra &id= as a parameter:

---
#[action]
fn remove(id: u64) {
    ENTRIES.remove(id)?;
}
---
{#each entries.iter().rev() as entry}
  <Note name=${entry.name.as_str()} message=${entry.message.as_str()} />
  <button action="?/remove&id=${entry.id}">Delete</button>
{:else}
  <p>No notes yet. Be the first.</p>
{/each}

The action has no redirect, so the page is drawn again with the row gone. Anyone can delete any note. Check who is signed in before shipping this.

Say Thanks

After a visitor signs, show "Thanks for signing" once on the next page.

Show Solution

Flash a message in the action, and show it with {@flash}:

---
// Entry and ENTRIES as before
#[action]
fn sign(entry: Entry) {
    ENTRIES.add(entry)?;
    cx.flash("Thanks for signing");
    redirect("/")
}

let entries = ENTRIES.all();
---

{@flash}

{@flash} writes <p class="flash" role="status">Thanks for signing</p>, or nothing. cx.flashed() reads the message in a block, and cx.flash_message() in markup. It waits in a cookie until a page reads it.

One Page per Note

Give each note its own page at /notes/1, /notes/2 and so on, with a 404 for a note that does not exist.

Show Solution

Move Entry and the table into src/db.rs, whose pub items every route file sees:

// src/db.rs
#[model]
struct Entry {
    #[validate(len = 1..=40)]
    name: String,
    #[validate(len = 1..=200)]
    message: String,
}
pub static ENTRIES: Table<Entry> = Table::saved();

Then create src/routes/notes/[id=int]/+page.wisp. [id=int] matches digits, and the block gets id as a u64:

---
let note = ENTRIES.get(id).or_404()?;
---

<title>${note.name}</title>

<h1>${note.name}</h1>
<p>${note.message}</p>

.or_404()? answers with the error page when the row is missing.

Next Steps

Is This Page Useful?

View This Page's Source

Type to search every docs page and heading.