Manual Recipes Signup Wizard

Signup Wizard

elements man recipes/signup-wizard Read as markdown

A three-page signup form on a single /signup route. A signupDrafts row keyed by an app-generated client id holds the user's inputs and the step they have reached. Each save RPC updates only its own columns and advances step to the next one. A page refresh reloads the draft and renders the same step. The final step collects the password, creates the user, deletes the draft, and signs the user in.

The client id is minted once by the browser (crypto.randomUUID()), kept in localStorage, and passed explicitly to every RPC as the draft key. The app owns this key because an Elements session starts at login, which keeps anonymous pages free of cookies and cacheable. See the note at the end.

There is no shared client-side form object. The template re-loads each step's inputs from the draft when it mounts, so the only state that survives a reload is the row in the database.

Migration

Terminal
elements create migration 'add users and signup drafts' -tables=users,signupDrafts
app/migrations/<timestamp>-add-users-and-signup-drafts.migration.sql
-- add users and signup drafts -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table users ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), email text not null unique, handle text not null unique, passwordHash text not null, displayName text not null, bio text not null default '' ); create trigger usersTouchUpdatedAt before update on users for each row execute function touchUpdatedAt(); create type signupStep as enum ('account', 'profile', 'review'); create table signupDrafts ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), expiresAt timestamptz not null default (now() + interval '24 hours'), clientId text not null unique, step signupStep not null default 'account', email text not null default '', handle text not null default '', displayName text not null default '', bio text not null default '' ); create trigger signupDraftsTouchUpdatedAt before update on signupDrafts for each row execute function touchUpdatedAt();

The signupStep Postgres enum gives the column ordered values: 'account' < 'profile' < 'review'. Each save RPC uses greatest(step, '<next-step>'::signupStep) to advance the step to its own next value without ever regressing a user who has reached a later stage.

signupDrafts.clientId is the app-generated id the browser passes on every rpc. The unique constraint enforces one in-progress draft per browser.

Page setup

Terminal
elements create page signup

The page scaffold writes app/pages/signup/{index.ts, template.ehtml, style.css, test.ts}. A page normally keeps its RPC in template.ehtml; three steps of interfaces and six RPC crowd the markup, so they go in a services.ts sibling. The route only checks whether the visitor is signed in; the template loads the draft on mount via an RPC, so the server never needs the client id.

app/pages/signup/services.ts
import { sql, session, ValidationError, FieldErrors } from "@elements/app"; export type Step = "account" | "profile" | "review"; export interface Draft { id: string; step: Step; email: string; handle: string; displayName: string; bio: string; } export interface AccountInput { email: string; handle: string; } export interface ProfileInput { displayName: string; bio: string; } export interface ReviewInput { password: string; } export interface SignupFields { email: string; handle: string; displayName: string; password: string; } const STEP_ORDER: Step[] = ["account", "profile", "review"]; export function stepIndex(s: Step): number { return STEP_ORDER.indexOf(s); } /** @rpc */ export function loadOrCreateDraft(clientId: string): Draft { let existing = sql<Draft>(` select id, step, email, handle, displayName, bio from signupDrafts where clientId = ${clientId} and expiresAt > now() `).first(); if (existing) { return existing; } return sql<Draft>(` insert into signupDrafts ( clientId ) values ( ${clientId} ) on conflict (clientId) do update set updatedAt = now() returning id, step, email, handle, displayName, bio `).firstOrThrow(); } /** @rpc */ export function saveAccount(clientId: string, input: AccountInput): Draft { return sql<Draft>(` update signupDrafts set email = ${input.email}, handle = ${input.handle}, step = greatest(step, 'profile'::signupStep) where clientId = ${clientId} returning id, step, email, handle, displayName, bio `).firstOrThrow(); } /** @rpc */ export function saveProfile(clientId: string, input: ProfileInput): Draft { return sql<Draft>(` update signupDrafts set displayName = ${input.displayName}, bio = ${input.bio}, step = greatest(step, 'review'::signupStep) where clientId = ${clientId} returning id, step, email, handle, displayName, bio `).firstOrThrow(); } /** @rpc */ export function signup(clientId: string, input: ReviewInput) { let draft = sql<Draft>(` select id, step, email, handle, displayName, bio from signupDrafts where clientId = ${clientId} `).firstOrThrow(); let errors: FieldErrors<SignupFields> = {}; if (!draft.email.includes("@")) { errors.email = ["must be a valid email"]; } else if (!sql(` select 1 from users where email = ${draft.email} `).empty()) { errors.email = ["already taken"]; } if (!/^[a-z0-9_]+$/.test(draft.handle)) { errors.handle = ["letters, numbers, and underscores only"]; } else if (!sql(` select 1 from users where handle = ${draft.handle} `).empty()) { errors.handle = ["already taken"]; } if (!draft.displayName.trim()) { errors.displayName = ["required"]; } if (input.password.length < 8) { errors.password = ["must be at least 8 characters"]; } if (Object.keys(errors).length > 0) { throw new ValidationError(errors); } let user = sql<{ id: string }>(` insert into users ( email, handle, passwordHash, displayName, bio ) values ( ${draft.email}, ${draft.handle}, crypt(${input.password}, genSalt('bf', 12)), ${draft.displayName}, ${draft.bio} ) returning id `).firstOrThrow(); sql(` delete from signupDrafts where clientId = ${clientId} `); session.login({ userId: user.id, userName: draft.displayName, }); }

Each save RPC takes the client id plus its own input type. Adding a fourth step means a new column and a new input interface, not a reshape of an existing one. The save RPC do not validate; light required-field checks happen in the browser via <input required>. Format and uniqueness checks are reserved for signup, so a reload of any earlier step never re-runs them.

greatest(step, 'profile'::signupStep) advances the step but never regresses one. A user who reached review, went back to fix their email, and clicked "next" stays at step = 'review'. The template uses that returned step to navigate.

app/pages/signup/index.ts
import { Request, Response, redirect, session } from "@elements/app"; import signup from "./template"; export default function route(req: Request, res: Response) { if (session.isLoggedIn()) { redirect("/"); return; } return new signup(); }

The route bounces a signed-in user home so the wizard is never visible after the account exists. Which step renders is decided on the client after the draft loads.

app/pages/signup/template.ehtml
import "./style.css"; import { ValidationError, FieldErrors, redirect } from "@elements/app"; import { Draft, Step, AccountInput, ProfileInput, ReviewInput, SignupFields, stepIndex, loadOrCreateDraft, saveAccount, saveProfile, signup, } from "./services"; function getClientId(): string { let id = localStorage.getItem("signupClientId"); if (!id) { id = crypto.randomUUID(); localStorage.setItem("signupClientId", id); } return id; } function go(step: { value: Step }, to: Step) { step.value = to; history.replaceState(null, "", `/signup?step=${to}`); } function setup( clientId: { value: string }, step: { value: Step }, account: AccountInput, profile: ProfileInput, ) { let cid = getClientId(); clientId.value = cid; let draft = loadOrCreateDraft(cid); account.email = draft.email; account.handle = draft.handle; profile.displayName = draft.displayName; profile.bio = draft.bio; let requested = new URLSearchParams(window.location.search).get("step") as Step | null; if (requested && stepIndex(requested) <= stepIndex(draft.step)) { step.value = requested; } else { step.value = draft.step; } } function onAccountSubmit(clientId: { value: string }, step: { value: Step }, account: AccountInput) { let updated = saveAccount(clientId.value, account); go(step, updated.step); } function onProfileSubmit(clientId: { value: string }, step: { value: Step }, profile: ProfileInput) { let updated = saveProfile(clientId.value, profile); go(step, updated.step); } function onReviewSubmit( clientId: { value: string }, review: ReviewInput, errors: { value: FieldErrors<SignupFields> }, ) { try { signup(clientId.value, review); redirect("/"); } catch (err: any) { if (err instanceof ValidationError) { errors.value = err.errors; } else { throw err; } } } <html class="page-signup" ( private clientId: { value: string } = { value: "" }, private step: { value: Step } = { value: "account" }, private account: AccountInput = { email: "", handle: "", }, private profile: ProfileInput = { displayName: "", bio: "", }, private review: ReviewInput = { password: "" }, private errors: { value: FieldErrors<SignupFields> } = { value: {} }, ) oninit={() => setup(clientId, step, account, profile)}> <main class="page-shell"> <p class="progress">step {step.value === "account" ? 1 : step.value === "profile" ? 2 : 3} of 3</p> <form e:if={step.value === "account"} onsubmit={() => onAccountSubmit(clientId, step, account)}> <h1>create your account</h1> <div class="field"> <label>email</label> <input type="email" value={account.email} required> </div> <div class="field"> <label>handle</label> <input type="text" value={account.handle} required> </div> <button type="submit">next</button> </form> <form e:elseif={step.value === "profile"} onsubmit={() => onProfileSubmit(clientId, step, profile)}> <h1>your profile</h1> <div class="field"> <label>display name</label> <input type="text" value={profile.displayName} required> </div> <div class="field"> <label>bio (optional)</label> <textarea value={profile.bio}/> </div> <button type="button" onclick={() => go(step, "account")}>back</button> <button type="submit">next</button> </form> <form e:else onsubmit={() => onReviewSubmit(clientId, review, errors)}> <h1>review and create</h1> <dl class="review"> <dt>email</dt> <dd>{account.email}</dd> <dt>handle</dt> <dd>{account.handle}</dd> <dt>display name</dt> <dd>{profile.displayName}</dd> <dt>bio</dt> <dd>{profile.bio || "(none)"}</dd> </dl> <p e:if={errors.value.email?.length} class="error"> email: {errors.value.email[0]} (<button type="button" onclick={() => go(step, "account")}>fix</button>) </p> <p e:if={errors.value.handle?.length} class="error"> handle: {errors.value.handle[0]} (<button type="button" onclick={() => go(step, "account")}>fix</button>) </p> <p e:if={errors.value.displayName?.length} class="error"> display name: {errors.value.displayName[0]} (<button type="button" onclick={() => go(step, "profile")}>fix</button>) </p> <div class="field"> <label>password</label> <input type="password" value={review.password} class={errors.value.password?.length && "is-error"} required> <p e:for={msg of errors.value.password ?? []} class="error">{msg}</p> </div> <button type="button" onclick={() => go(step, "profile")}>back</button> <button type="submit">create account</button> </form> </main> </html>

Structure to notice:

  • setup runs on mount: it reads (or mints) the client id from localStorage, calls loadOrCreateDraft with it, and seeds the account and profile inputs from the returned draft by mutating those objects in place. The bindings on each step's inputs write back to those same objects. On submit, the matching save RPC is called with the client id and the matching input shape. Each input type is its own; the template never treats the form as one object.
  • Each save handler navigates to the step returned by the rpc. Because the save RPC advances step via greatest, a user fixing an earlier field is routed back to whatever step they had reached, not bumped forward through every later step again.
  • clientId, step, and errors are wrapped in { value: ... } so a handler can reassign them (step.value = ..., errors.value = err.errors). Plain reassignment of a function parameter would not flow back to template state, so the wrapper gives the handler a reference it can mutate.
  • go also mirrors the step into the URL with history.replaceState, so a refresh re-runs setup, reads ?step= back, clamps it against the draft's saved step, and renders the same step with values pre-filled from the database.

Routes

Register the page:

index.ts
import signup from "#app/pages/signup"; // ... app.route("/signup", signup);

Notes

  • Draft cleanup. expiresAt is set to 24 hours from creation. A nightly cron job can run delete from signupDrafts where expiresAt < now() to reap abandoned drafts. Until then, the unique clientId constraint keeps storage bounded to one row per browser.
  • Password placement. The password column lives only on users. Collecting it on the final step means a half-completed signup never leaves credentials in the database. Moving the password earlier would require either storing a hash in the draft or rehashing on every save.
  • Adding a step. Add a value to the signupStep enum, add a column to signupDrafts, add a new input interface and RPC in services.ts, and add an e:elseif branch in the template. The other steps are not touched.
  • Per-step validation. This recipe validates only on signup. To add live per-field feedback (format on blur, availability check), reuse the pattern from elements man recipes form-validation: pure validators imported by both the template and the save RPC, called from onblur on the relevant inputs.
  • Anonymous draft keying. Key the in-progress signup on an id the app mints: crypto.randomUUID() stored in localStorage, passed to every draft RPC as an explicit clientId argument. It persists across reloads for that browser, so the draft row is reachable on every navigation. A session starts at login so anonymous responses carry no cookie (elements man session).