Wizards are session-backed multi-step forms where each step is validated independently before the user advances. The final "commit" step assembles all validated step data and performs the real write.

Autumn ships a WizardContext runtime struct plus a generator that scaffolds the full route file. Start with the generator and edit the generated TODOs into real field definitions.

Quick start

Shell
autumn generate wizard checkout shipping payment review

This emits three files:

Code
src/wizards/checkout.rs        # step structs + GET/POST handlers + confirm/commit/cancel
src/wizards/mod.rs             # pub mod checkout;  (created or appended)
tests/checkout_wizard.rs       # ignored integration test skeletons

Mount the routes and add mod wizards; in src/main.rs:

Rust
mod wizards;

// inside autumn_web::app() builder:
.routes(routes![
    wizards::checkout::show_shipping,
    wizards::checkout::submit_shipping,
    wizards::checkout::show_payment,
    wizards::checkout::submit_payment,
    wizards::checkout::show_review,
    wizards::checkout::submit_review,
    wizards::checkout::show_confirm,
    wizards::checkout::commit,
    wizards::checkout::cancel,
])

Then fill in the // TODO sections in src/wizards/checkout.rs.

The generated file layout

Code
WIZARD_NAME   = "checkout"
STEPS         = ["shipping", "payment", "review"]
wizard_context(session) -> WizardContext

ShippingForm  { /* fill in */ }
PaymentForm   { /* fill in */ }
ReviewForm    { /* fill in */ }

GET  /checkout/shipping  → show_shipping    (guard → form)
POST /checkout/shipping  → submit_shipping  (validate → save → redirect)
GET  /checkout/payment   → show_payment
POST /checkout/payment   → submit_payment
GET  /checkout/review    → show_review
POST /checkout/review    → submit_review
GET  /checkout/confirm   → show_confirm     (summary + CSRF-protected submit button)
POST /checkout/commit    → commit           (assemble all steps → write → clear)
POST /checkout/cancel    → cancel           (clear → redirect)

commit and cancel are POST-only. Commit mutates state; cancel discards accumulated session data. Using GET for either would make them vulnerable to CSRF or prefetching.

WizardContext

WizardContext is the runtime handle for a wizard session. The generator produces a wizard_context(session: Session) -> WizardContext helper so you never construct it directly in handler code.

Rust
use autumn_web::wizard::{WizardContext, wizard_progress};

pub fn wizard_context(session: Session) -> WizardContext {
    WizardContext::new(session, WIZARD_NAME, STEPS.iter().map(|s| s.to_string()))
}

guard_step

Ensures the user cannot jump to step N without completing steps 1..N-1. If a prerequisite step is missing, guard_step returns an Err(redirect_url) pointing at the first incomplete step.

Rust
if let Err(redirect_url) = wizard.guard_step("payment", "/checkout").await {
    return Redirect::to(&redirect_url).into_response();
}

The second argument is the wizard URL prefix. guard_step appends /{first_incomplete_step} to it, so passing /checkout redirects to /checkout/shipping, /checkout/payment, etc. Passing the full step path (e.g. /checkout/shipping) would produce /checkout/shipping/shipping.

save_step

Serializes a step struct into the session under a namespaced key. The key is {wizard_name}/{step_name} so multiple wizards in the same session cannot collide.

Rust
wizard.save_step("shipping", &validated_data).await?;

T must implement Serialize + Sync.

step_data

Deserializes a previously saved step. Returns None if the step hasn't been saved yet or if deserialization fails.

Rust
let shipping: Option<ShippingForm> = wizard.step_data("shipping").await;
let data = shipping.unwrap_or_default(); // safe — generated structs derive Default

is_step_complete

Returns true if the step has valid JSON saved in the session. Used internally by guard_step and first_incomplete_step.

Rust
if wizard.is_step_complete("payment").await {
    // ...
}

first_incomplete_step

Returns the name of the first step that is not yet complete, or None if all steps are done. Used in show_confirm and commit to guard against direct URL access.

Rust
if let Some(incomplete) = wizard.first_incomplete_step().await {
    return Redirect::to(&format!("/checkout/{incomplete}")).into_response();
}

clear

Removes all step data for this wizard from the session. Call at the end of commit (after the write succeeds) and in cancel.

Rust
wizard.clear().await;

wizard_progress

Renders an accessible <ol> progress indicator that marks the active step. Returns Markup so you can embed it directly in a Maud template.

Rust
html! {
    (wizard_progress(&wizard, "payment").await)
    // ... rest of the step form
}

Pass "confirm" as the step name on the confirm page.

Step structs

Each step is a plain Rust struct that derives Serialize, Deserialize, Validate, and Default. The generator emits empty structs with a TODO comment; replace the comment with the fields that belong to that step.

Rust
#[derive(Debug, Default, Clone, Serialize, Deserialize, Validate)]
pub struct ShippingForm {
    #[validate(length(min = 1))]
    pub name: String,
    pub address_line_1: String,
    pub city: String,
    pub postcode: String,
}

Validation attributes from the validator crate (url, email, length(min=N, max=N), range, custom validators) are all supported. ChangesetForm<T> calls T::validate() in into_valid() and collects field-level errors automatically.

CSRF

The generated GET handlers extract Option<CsrfToken> and Option<CsrfFormField> so the wizard works with and without the CSRF middleware enabled. When the middleware is active, both extractors resolve to Some; when absent, they resolve to None and the form omits the hidden field rather than panicking.

The generated show_confirm handler renders the hidden CSRF input manually (not via ChangesetForm) because the confirm page has two forms — commit and cancel — each needing the token.

Long-lived drafts (TurboTax-style persistence)

By default, wizard state lives in the session and expires with it (hours to days). For wizards where the user needs to fill things in over several visits — tax forms, lengthy onboarding flows, multi-day quote builders — you can persist step data to a database using three helpers on WizardContext:

MethodWhat it does
draft_key(user_key)Returns a stable "wizard:{name}:{user_key}" string to use as a DB column value or cache key
export_draft()Serializes all saved steps to a single serde_json::Value object keyed by step name
restore_draft(json)Loads from that object back into the session; steps already in the session are left unchanged

The framework provides the serialization format and the session↔draft bridge. You provide the storage backend and the auth context.

Saving a draft

Call export_draft whenever you want to snapshot progress — on each step submission, on a "save and exit" button, or in the commit handler if you want to preserve the draft until the user explicitly discards it:

Rust
#[post("/checkout/shipping")]
async fn submit_shipping(
    session: Session,
    current_user: CurrentUser,
    form: ChangesetForm<ShippingForm>,
    db: Db,
) -> impl IntoResponse {
    let wizard = wizard_context(session.clone());
    // ... validate and save to session as usual ...
    wizard.save_step("shipping", &data).await?;

    // Persist draft so the user can pick up where they left off.
    let key   = wizard.draft_key(&current_user.id.to_string());
    let draft = wizard.export_draft().await.to_string();
    db.upsert_wizard_draft(&key, &draft).await?;

    Redirect::to("/checkout/payment").into_response()
}

Restoring a draft

Restore on the first step's GET handler, before re-populating the form from the session. restore_draft only writes steps that are missing from the current session — if the user has already filled a step in this session, their current work is preserved:

Rust
#[get("/checkout/shipping")]
async fn show_shipping(
    session: Session,
    current_user: CurrentUser,
    db: Db,
) -> impl IntoResponse {
    let wizard = wizard_context(session.clone());

    // Restore a saved draft when the session is empty.
    if wizard.first_incomplete_step().await.as_deref() == Some("shipping") {
        let key = wizard.draft_key(&current_user.id.to_string());
        if let Ok(Some(json)) = db.load_wizard_draft(&key).await {
            if let Ok(draft) = serde_json::from_str(&json) {
                wizard.restore_draft(&draft).await;
            }
        }
    }

    // The rest of the handler is unchanged — step_data now returns the
    // restored values if the session was empty.
    let data: ShippingForm = wizard.step_data("shipping").await.unwrap_or_default();
    // ...
}

Cleaning up

Delete the draft row after a successful commit so stale data doesn't pre-populate the next run:

Rust
#[post("/checkout/commit")]
async fn commit(session: Session, current_user: CurrentUser, db: Db) -> impl IntoResponse {
    let wizard = wizard_context(session.clone());
    // ... assemble data and write ...
    wizard.clear().await;
    db.delete_wizard_draft(&wizard.draft_key(&current_user.id.to_string())).await?;
    Redirect::to("/orders").into_response()
}

A minimal draft table looks like:

Sql
CREATE TABLE wizard_drafts (
    key        TEXT PRIMARY KEY,          -- wizard.draft_key(&user_id.to_string())
    data       JSONB NOT NULL,
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

Session storage

Wizard state lives in the session store, namespaced by wizard name. If the session expires, all step data is lost and the user must restart from step 1 (the guards redirect there automatically). This is intentional: partial wizard data that outlives its session would be stale.

For wizards where step data is expensive to re-enter, consider increasing the session TTL in autumn.toml:

TOML
[session]
ttl_seconds = 3600  # 1 hour

Worked example

See examples/bookmarks/src/wizards/add_bookmark.rs for a fully filled-in wizard that saves a Bookmark through the repository. The generator skeleton is visible in the commit that added src/wizards/checkout.rs; the diff between skeleton and final is exactly the work left after generation.

Validation and the 422 pattern

Submit handlers follow the same 422/re-render pattern as scaffold routes:

Rust
match form.into_valid() {
    Ok(data) => {
        wizard.save_step("shipping", &data).await?;
        Redirect::to("/checkout/payment").into_response()
    }
    Err(form) => (
        StatusCode::UNPROCESSABLE_ENTITY,
        html! {
            (wizard_progress(&wizard, "shipping").await)
            (form.form_tag("/checkout/shipping", "post", html! {
                // same fields as the GET handler
            }))
        },
    ).into_response(),
}

ChangesetForm::into_valid() re-packages the form with errors attached so form.text_input("name", "") automatically renders error messages alongside the relevant field.

Name constraints

The wizard name and all step names must be valid Rust identifiers:

  • Only ASCII letters, digits, and underscores.
  • Must start with an ASCII letter or _.
  • Must not be a Rust keyword (type, mod, crate, …).
  • Step names confirm, commit, and cancel are reserved — the generator emits handlers with those names and collisions would produce a compile error.
  • Duplicate step names (after snake_case normalization) are rejected.

Use underscores for multi-word names: add_bookmark, shipping_address. The generator converts PascalCase step names to snake_case automatically (ShippingAddressshipping_address), but the CLI rejects hyphens.