Signup with Email Confirmation
elements man recipes/signup-confirmation Read as markdownA 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
elements create migration 'add users and verifications' -tables=users,userVerifications
Open the generated file and add the columns specific to this feature.
app/migrations/<timestamp>-add-users-and-verifications.migration.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
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:
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<SignupForm> {
let errors: FieldErrors<SignupForm> = {};
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:
import signup from "./template";
export default function route(req, res) {
return new signup();
}
Signup template
app/pages/signup/template.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<SignupForm> {
try {
signupUser(form);
ui.submitted = true;
return {};
} catch (err: any) {
if (err instanceof ValidationError) {
return err.errors;
}
throw err;
}
}
<html class="signup"
(private form: SignupForm = emptyForm(),
private errors: FieldErrors<SignupForm> = {},
private ui: UiState = { submitted: false })>
<div e:if={ui.submitted}>
<h1>check your email</h1>
<p>we've sent a verification link to <strong>{form.email}</strong>. click the link to finish creating your account.</p>
</div>
<form e:else onsubmit={() => errors = submit(form, ui)}>
<h1>sign up</h1>
<div class="field">
<label>email</label>
<input type="email" value={form.email} class={errors.email?.length && "is-error"}>
<p e:for={msg of errors.email ?? []} class="error">{msg}</p>
</div>
<div class="field">
<label>handle</label>
<input type="text" value={form.handle} class={errors.handle?.length && "is-error"}>
<p e:for={msg of errors.handle ?? []} class="error">{msg}</p>
</div>
<div class="field">
<label>password</label>
<input type="password" value={form.password} class={errors.password?.length && "is-error"}>
<p e:for={msg of errors.password ?? []} class="error">{msg}</p>
</div>
<button type="submit">create account</button>
</form>
</html>
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
elements create email verify
That writes app/emails/verify/{index.html, style.css}. Update index.html to:
app/emails/verify/index.html:
import "./style.css";
/** @email */
<html (verifyUrl: string)>
<body class="email verify">
<h1>verify your email</h1>
<p>click the link below to finish creating your account. the link expires in 24 hours.</p>
<p>
<a href={verifyUrl}>{verifyUrl}</a>
</p>
<p>if you didn't sign up, you can safely ignore this email.</p>
</body>
</html>
The scaffolder marks email templates server-only and inlines reachable CSS into
a single <style> tag at render time. The email has no browser bundle and no
external resources, so it renders the same in every client.
Verification email job
elements create job SendVerificationEmail -fields='to: string, verificationId: string'
That writes app/jobs/send-verification-email.ts with a typed payload. Update
the run() body:
app/jobs/send-verification-email.ts:
import { Job, email, getAppUrl } from "@elements/app";
import verifyEmail from "#app/emails/verify";
export interface SendVerificationEmailJobFields {
to: string;
verificationId: string;
}
/** @job */
export class SendVerificationEmailJob extends Job<SendVerificationEmailJobFields> {
run() {
let verifyUrl = `${getAppUrl()}/verify?token=${this.fields.verificationId}`;
email({
to: this.fields.to,
subject: "verify your email",
body: new verifyEmail({ verifyUrl }),
});
}
}
getAppUrl() resolves the app's external URL from the active environment:
https://<domain> when a domain is set for the environment (with the scheme
following the ssl setting), http://localhost:<port> in development with no
domain, or http://<first-deploy-box-ip> on a deployment machine with no
domain. run() is sync-style; Elements propagates async, so the email(...)
call awaits internally.
The signup handler in app/pages/signup/services.ts schedules an instance with
new SendVerificationEmailJob({ to, verificationId }).schedule();. The job's
row commits with the surrounding transaction. The worker picks it up after the
user and verification rows are durable.
Verify route
elements create page verify
The verify page has no rpc; everything happens in the route handler. The
Verification interface is local to index.ts (the template does not need it).
app/pages/verify/index.ts:
import { sql, tx, session, NotAcceptableError, AuthError } from "@elements/app";
import verify from "./template";
interface Verification {
id: string;
userId: string;
expiresAt: Date;
email: string;
handle: string;
}
export default function route(req, res) {
let token = req.query.token as string | undefined;
if (!token) {
throw new NotAcceptableError("missing token");
}
let v = sql<Verification>(
`select v.id, v.userId, v.expiresAt, u.email, u.handle
from userVerifications v
join users u on u.id = v.userId
where v.id = ${token}`,
).firstOrThrow("verification token not found");
if (v.expiresAt < new Date()) {
throw new AuthError("link expired, please sign up again");
}
tx(() => {
sql(`update users set verifiedAt = now() where id = ${v.userId}`);
sql(`delete from userVerifications where id = ${v.id}`);
});
session.login({ userId: v.userId, userName: v.handle });
return new verify({ handle: v.handle });
}
NotAcceptableError (safe 406) covers the case where the request is missing the
token query parameter; the request shape is wrong. NotFoundError (safe 404)
covers a token that doesn't match any userVerifications row. AuthError (safe
401) covers an expired link. The tx() makes the mark-verified and delete-token
atomic, so the token is consumed exactly once.
app/pages/verify/template.html:
import "./style.css";
<html class="verify" (handle: string)>
<h1>welcome, {handle}!</h1>
<p>your email is verified.</p>
<p><a href="/">go to your dashboard</a></p>
</html>
Routes
Register the pages and the job in index.ts:
import signup from "#app/pages/signup";
import verify from "#app/pages/verify";
// ...
app.route("/signup", signup);
app.route("/verify", verify);
The SendVerificationEmailJob class carries the /** @job */ build tag, so
it's discovered and registered with the worker pool automatically. No
app.job(...) call is needed.
Notes
- The verification id is the token. There's no separate random-string column.
uuidv7 is unguessable (122 bits of entropy) and unique by primary key.
One-time use comes from the
delete from userVerificationsinside the verifytx(). expiresAtis 24 hours by default in this recipe; tune in the signup rpc if you want a shorter or longer window.- The email is sent from a job, not synchronously inside the signup rpc. The rpc returns immediately so the browser shows "check your email" without a slow SMTP round-trip on the request thread. Transient mail-server failures retry automatically through the job queue. The email is never sent for a signup that rolled back, because the job row commits with the surrounding transaction.
- For a friendlier "this email is already taken, did you mean to sign in?" UX,
catch the
ValidationErrorcarryingerrors.email = ["already taken"]in the template and render a "sign in" link. getAppUrl()resolves the app's external URL from the active environment. Setdomainandsslper environment inconfig.jsocso the link in the email points at the right host in development, staging, and production.