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. 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:

  • 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 in 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 createAccount. 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. 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 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, and it never touches session state.