# Signup Wizard 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. A session in Elements exists only after login, so anonymous draft state cannot hang off the session. The app owns the key instead. See the note at the end. The wizard does not depend on a single shared client-side form object. Each step's rpc takes its own input shape and updates only the columns that belong to that step. The template re-loads its inputs from the draft when it mounts, so the only state that survives between reloads is the row in the database. ## Migration ```bash elements create migration 'add users and signup drafts' -tables=users,signupDrafts ``` `app/migrations/-add-users-and-signup-drafts.migration.sql`: ```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, ''::signupStep)` to advance the step to its own next value without ever regressing a user who has reached a later stage. The password column lives only on `users` and is collected on the final step, so a half-completed signup never leaves credentials in the database. `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 ```bash elements create page signup ``` The page scaffold writes `app/pages/signup/{index.ts, template.html, style.css, test.ts}`. Add a `services.ts` sibling by hand for the rpc functions and the shared interfaces both `index.ts` and `template.html` need. The route only checks whether the visitor is already signed in; the draft is loaded by the template on mount via an rpc, so the server never needs the client id. `app/pages/signup/services.ts`: ```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( `select id, step, email, handle, displayName, bio from signupDrafts where clientId = ${clientId} and expiresAt > now()`, ).first(); if (existing) { return existing; } return sql( `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( `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( `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 createAccount(clientId: string, input: ReviewInput) { let draft = sql( `select id, step, email, handle, displayName, bio from signupDrafts where clientId = ${clientId}`, ).firstOrThrow(); let errors: FieldErrors = {}; 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 ``. Format and uniqueness checks are reserved for `createAccount`, 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. For live per-field validation (format on blur, availability check) layered on top of these save rpc, see `elements man recipes form-validation`. `app/pages/signup/index.ts`: ```ts import { redirect, session } from "@elements/app"; import signup from "./template"; export default function route(req, res) { if (session.isLoggedIn()) { redirect("/"); return; } return new signup(); } ``` The route only bounces a signed-in user to the home route 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.html`: ```html import "./style.css"; import { ValidationError, FieldErrors, redirect } from "@elements/app"; import { Draft, Step, AccountInput, ProfileInput, ReviewInput, SignupFields, stepIndex, loadOrCreateDraft, saveAccount, saveProfile, createAccount, } 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 submitAccount(clientId: { value: string }, step: { value: Step }, account: AccountInput) { let updated = saveAccount(clientId.value, account); go(step, updated.step); } function submitProfile(clientId: { value: string }, step: { value: Step }, profile: ProfileInput) { let updated = saveProfile(clientId.value, profile); go(step, updated.step); } function submitReview( clientId: { value: string }, review: ReviewInput, errors: { value: FieldErrors }, ) { try { createAccount(clientId.value, review); redirect("/"); } catch (err: any) { if (err instanceof ValidationError) { errors.value = err.errors; } else { throw err; } } } } = { value: {} }) oninit={() => setup(clientId, step, account, profile)}>

step {step.value === "account" ? 1 : step.value === "profile" ? 2 : 3} of 3

submitAccount(clientId, step, account)}>

create your account

submitProfile(clientId, step, profile)}>

your profile