Every SaaS has the same problem the day after launch: a user signs up with a typo in their email address, cannot receive any notifications, and blames you when things go wrong. Email verification is the fix -- and it is simpler to wire up than most founders expect.
This post walks through the full pattern: a signed JWT link sent via Resend on signup, a verify endpoint that flips a column in Drizzle ORM, and a guard in the service layer that blocks unverified users from protected actions.
The flow has four steps:
Add one nullable column to your users table:
// modules/user/user.schema.ts
export const userTable = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
emailVerifiedAt: timestamp("email_verified_at"), // null = unverified
createdAt: timestamp("created_at").notNull().defaultNow(),
});
Run the migration:
npm run db:generate
npm run db:migrate
Verification tokens are short-lived JWTs signed with your JWT_SECRET. Generate them in the service, never in the route:
// modules/user/user.service.ts
import jwt from "jsonwebtoken";
import { emailService } from "@/lib/email";
import React from "react";
import { VerifyEmail } from "@/emails/VerifyEmail";
const VERIFY_EXPIRES_IN = "24h";
export async function sendVerificationEmail(userId: string, email: string) {
const token = jwt.sign(
{ sub: userId, purpose: "email-verification" },
process.env.JWT_SECRET!,
{ expiresIn: VERIFY_EXPIRES_IN }
);
const verifyUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/verify-email?token=${token}`;
await emailService.sendEmail({
to: email,
subject: "Confirm your email address",
react: React.createElement(VerifyEmail, {
appName: "YourApp",
verifyUrl,
}),
});
}
Call this right after you insert the user during registration -- inside the same service function, after the DB write succeeds.
A GET route that the email link points to. It validates the token, checks purpose, and updates the user:
// app/api/auth/verify-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
import { handleError, HttpError } from "@/lib/errors";
import { userRepo } from "@/modules/user";
export async function GET(req: NextRequest) {
try {
const token = req.nextUrl.searchParams.get("token");
if (!token) throw new HttpError(400, "Missing token");
let payload: { sub: string; purpose: string };
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as typeof payload;
} catch {
throw new HttpError(400, "Invalid or expired token");
}
if (payload.purpose !== "email-verification") {
throw new HttpError(400, "Wrong token type");
}
await userRepo.markEmailVerified(payload.sub);
// Redirect to the app with a success flag
return NextResponse.redirect(
new URL("/dashboard?verified=1", req.url)
);
} catch (error: unknown) {
return handleError(error);
}
}
The repo method is a simple update:
// modules/user/user.repo.ts
async markEmailVerified(userId: string) {
await db
.update(userTable)
.set({ emailVerifiedAt: new Date() })
.where(eq(userTable.id, userId));
}
Do not put this check in routes -- put it in the service functions that require a verified email. That way it is enforced regardless of which route calls the service:
// shared helper in modules/user/user.service.ts
export async function requireVerifiedEmail(userId: string) {
const user = await userRepo.findById(userId);
if (!user) throw new HttpError(404, "User not found");
if (!user.emailVerifiedAt) {
throw new HttpError(403, "Please verify your email address before continuing");
}
return user;
}
Then in any service that needs it:
export async function createPost(userId: string, data: CreatePostInput) {
await requireVerifiedEmail(userId); // fails fast if unverified
// ... rest of the function
}
Users who miss the email or let it expire need a way to get a new one. A simple POST endpoint:
// app/api/auth/resend-verification/route.ts
export async function POST(req: NextRequest) {
try {
const user = await getUserFromRequest(req);
if (user.emailVerifiedAt) {
return NextResponse.json({ message: "Already verified" });
}
await sendVerificationEmail(user.id, user.email);
return NextResponse.json({ message: "Verification email sent" });
} catch (error: unknown) {
return handleError(error);
}
}
On the frontend, show a banner when emailVerifiedAt is null and wire the button to this endpoint.
In your main layout (server component), check the user and render a banner if unverified:
// components/layout/VerificationBanner.tsx
"use client";
export function VerificationBanner({ email }: { email: string }) {
const [sent, setSent] = useState(false);
async function resend() {
await fetch("/api/auth/resend-verification", { method: "POST",
headers: { Authorization: `Bearer ${getToken()}` } });
setSent(true);
}
return (
<div className="bg-yellow-50 border-b border-yellow-200 px-4 py-2 text-sm text-yellow-800 flex items-center justify-between">
<span>Please verify your email address ({email}) to unlock all features.</span>
{sent ? (
<span className="text-green-700">Email sent!</span>
) : (
<button onClick={resend} className="underline ml-2">Resend</button>
)}
</div>
);
}
Building email verification after launch means you have to backfill existing users, handle the "unverified but paying" edge case, and update every place in the code that did not check. Starting with it baked in costs you an afternoon and saves you a week of cleanup.
The claude-code-boilerplates already ships with JWT helpers in lib/auth.ts, Resend wired up in lib/email.ts, and the HttpError pattern in lib/errors/ -- so the pieces above slot in directly without any new dependencies.
[ ] Add emailVerifiedAt column + run migration
[ ] sendVerificationEmail() called in registration service
[ ] GET /api/auth/verify-email validates JWT and flips column
[ ] requireVerifiedEmail() guard added to sensitive service functions
[ ] POST /api/auth/resend-verification for expired links
[ ] VerificationBanner shown in layout when emailVerifiedAt is null
[ ] Redirect to /dashboard?verified=1 on success -- show a toast
Every step fits inside your existing module structure. No new packages required beyond what is already in the boilerplate.
Clone claude-code-boilerplates and have email verification running alongside registration in under an hour.