Signup Wizard
elements man recipes/signup-wizard Read as markdownA 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
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. 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
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:
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 createAccount(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
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:
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:
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<SignupFields> },
) {
try {
createAccount(clientId.value, review);
redirect("/");
} catch (err: any) {
if (err instanceof ValidationError) {
errors.value = err.errors;
} else {
throw err;
}
}
}
<html class="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)}>
<p class="progress">step {step.value === "account" ? 1 : step.value === "profile" ? 2 : 3} of 3</p>
<form e:if={step.value === "account"} onsubmit={() => submitAccount(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={() => submitProfile(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={() => submitReview(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>
</html>
Structure to notice:
setupruns on mount: it reads (or mints) the client id fromlocalStorage, callsloadOrCreateDraftwith it, and seeds theaccountandprofileinputs 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
stepviagreatest, 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, anderrorsare 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.goalso mirrors the step into the URL withhistory.replaceState, so a refresh re-runssetup, 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 in index.ts:
import signup from "#app/pages/signup";
// ...
app.route("/signup", signup);
Notes
- Draft cleanup.
expiresAtis set to 24 hours from creation. A nightly cron job can rundelete from signupDrafts where expiresAt < now()to reap abandoned drafts. Until then, the uniqueclientIdconstraint 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
signupStepenum, add a column tosignupDrafts, add a new input interface and rpc inservices.ts, and add ane:elseifbranch in the template. The other steps are not touched. - Per-step validation. This recipe validates only on
createAccount. To add live per-field feedback (format on blur, availability check), reuse the pattern fromelements man recipes form-validation: pure validators imported by both the template and the save rpc, called fromonbluron the relevant inputs. - Anonymous draft keying. A session in Elements exists only after login, so
there is no session id to key an in-progress signup on. The app mints its own
key instead:
crypto.randomUUID()stored inlocalStorage, passed to every draft rpc as an explicitclientIdargument. It persists across reloads for that browser, so the draft row is reachable on every navigation, and it never touches session state.