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 have | Run |
|---|---|
| A VPS or server | wisp build, then wisp service install |
| A container host | wisp deploy init docker |
| Cloudflare, Deno, Vercel, Netlify or Lambda | wisp 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
- Quick Start: the same ideas, shorter.
- Actions, forms and UI: validation rules and uploads.
- Data, files and jobs: tables, files and background jobs.
- Browser code: runes, directives, islands and the router.
- Testing and mixing with Rust code: testing an app in process.
Is This Page Useful?