# Signup with Email Confirmation A signup flow that creates an unverified user, emails them a one-time verification link, and logs them in when they click it. A separate `userVerifications` table tracks tokens (the row's id is the token). A background job sends the email asynchronously. A `/verify` page redeems the token. Every file in this recipe starts from an `elements create` scaffold. Fill in the parts the recipe shows; leave the rest of the scaffold as-is. ## Migration ```bash elements create migration 'add users and verifications' -tables=users,userVerifications ``` Open the generated file and add the columns specific to this feature. `app/migrations/-add-users-and-verifications.migration.sql`: ```sql -- add users and verifications -- 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, verifiedAt timestamptz ); create trigger usersTouchUpdatedAt before update on users for each row execute function touchUpdatedAt(); create table userVerifications ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), userId uuid not null references users(id) on delete cascade, expiresAt timestamptz not null ); create trigger userVerificationsTouchUpdatedAt before update on userVerifications for each row execute function touchUpdatedAt(); ``` `users.verifiedAt` is null until the user clicks the verification link. `userVerifications.id` is the token: a random uuidv7, unique by primary key, embedded in the verification URL. `expiresAt` lets the verify route reject stale links. ## Signup services ```bash elements create page signup ``` That writes `app/pages/signup/{index.ts, template.html, style.css, test.ts}` and prints the `/signup` route line to add to `index.ts`. Add a `services.ts` sibling by hand for the `SignupForm` interface and the `signupUser` rpc. `app/pages/signup/services.ts`: ```ts import { sql, tx, ValidationError, FieldErrors } from "@elements/app"; import { SendVerificationEmailJob } from "#app/jobs/send-verification-email"; export interface SignupForm { email: string; handle: string; password: string; } /** @rpc */ export function signupUser(form: SignupForm) { let errors = validateSignup(form); if (Object.keys(errors).length > 0) { throw new ValidationError(errors); } let verificationId = tx(() => { let user = sql<{ id: string }>( `insert into users (email, handle, passwordHash) values (${form.email}, ${form.handle}, crypt(${form.password}, genSalt('bf', 12))) returning id`, ).firstOrThrow(); let verification = sql<{ id: string }>( `insert into userVerifications (userId, expiresAt) values (${user.id}, now() + interval '24 hours') returning id`, ).firstOrThrow(); return verification.id; }); new SendVerificationEmailJob({ to: form.email, verificationId }).schedule(); } function validateSignup(form: SignupForm): FieldErrors { let errors: FieldErrors = {}; if (!form.email.includes("@")) { errors.email = ["must be a valid email"]; } else if (!sql(`select 1 from users where email = ${form.email}`).empty()) { errors.email = ["already taken"]; } if (!/^[a-z0-9_]+$/.test(form.handle)) { errors.handle = ["letters, numbers, and underscores only"]; } else if (!sql(`select 1 from users where handle = ${form.handle}`).empty()) { errors.handle = ["already taken"]; } if (form.password.length < 8) { errors.password = ["must be at least 8 characters"]; } return errors; } ``` The `tx()` wraps the two inserts so a failure leaves no half-state behind. Scheduling the job outside the `tx()` is intentional: the job row commits with the surrounding transaction; the worker picks it up after the user and verification rows are durable. For richer per-field validation (live blur, multiple errors per field), see `elements man recipes form-validation`. ## Signup route `app/pages/signup/index.ts`: ```ts import signup from "./template"; export default function route(req, res) { return new signup(); } ``` ## Signup template `app/pages/signup/template.html`: ```html import "./style.css"; import { ValidationError, FieldErrors } from "@elements/app"; import { SignupForm, signupUser } from "./services"; interface UiState { submitted: boolean; } function emptyForm(): SignupForm { return { email: "", handle: "", password: "" }; } function submit(form: SignupForm, ui: UiState): FieldErrors { try { signupUser(form); ui.submitted = true; return {}; } catch (err: any) { if (err instanceof ValidationError) { return err.errors; } throw err; } } = {}, private ui: UiState = { submitted: false })>

check your email

we've sent a verification link to {form.email}. click the link to finish creating your account.

errors = submit(form, ui)}>

sign up

{msg}

{msg}

{msg}

``` After a successful submit, `ui.submitted` flips and the "check your email" branch renders. The form is still reactively bound, so a server-side rejection (for example, the email turns out to be taken after passing the client check) populates `errors` and the form re-renders with inline messages. ## Email template ```bash elements create email verify ``` That writes `app/emails/verify/{index.html, style.css}`. Update `index.html` to: `app/emails/verify/index.html`: ```html import "./style.css"; /** @email */

verify your email

click the link below to finish creating your account. the link expires in 24 hours.

{verifyUrl}

if you didn't sign up, you can safely ignore this email.

``` The scaffolder marks email templates server-only and inlines reachable CSS into a single `