Email Verification System in Next.js and tRPC with Resend
Published from Publish Studio In the last two posts from the series "Build a Full-Stack App with tRPC and Next.js App Router", I shared a lot about authentication and authorization. But I left out one crucial part which is verifying user email upon sign up. We have to make sure the email belongs to that user only and is not fake. For this, we are going to send an OTP to the given email after signup and if the OTP sent by the user is valid, set the email verified to true. As usual, the project remains same - Finance Tracker [(GitHub repo). Backend First, add emailVerified field to user schema: // backend/src/modules/user/user.schema.ts export const users = pgTable("users", { ... emailVerified: boolean("email_verified").default(false), ... }); Push changes to the database: yarn drizzle-kit push Set up Resend We are going to use Resend to send emails. One of the reasons I'm recommending Resend is it's a lot easier to set up and no verifications shit like SendGrid. The only thing you need to verify is your domain. Sign up for a free Resend account. Verify your domain Get Resend API key Modify .env: RESEND_API_KEY=your_resend_api_key EMAIL_FROM="John Doe " Install Resend Node.js SDK: yarn add resend Create src/utils/resend.ts and export resend client for use anywhere in our code: import { Resend } from "resend"; export const resend = new Resend(process.env.RESEND_API_KEY); Implement send OTP email Inside modules/auth/auth.service.ts, write a function to send an email with OTP. We can generate a random OTP with Math.floor(100000 + Math.random() * 900000). Store this OTP in Redis and set your desired expiration time. // src/modules/auth/auth.service.ts async sendOtpEmail(email: string) { const otp = Math.floor(100000 + Math.random() * 900000); try { await redis.set(`otp:${email}`, otp, "EX", 5 * 60); // 5 minutes await resend.emails.send({ from: process.env.EMAIL_FROM!, to: email, subject: "OTP to verify your email", text: `Your OTP is ${otp}. It will expire in 5 minutes.`, }); return { success: true, }; } catch (error) { console.log(error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Something went wrong", }); } } Now, it's up to you when you want your users to verify their - 1. Right after sign up or 2. Let users experience the product first and limit how long and what they can access the product based on email verification. For now, let's ask for OTP right after signing up. So put in register method: // src/modules/auth/auth.service.ts ... async register(data: typeof users.$inferInsert) { ... const newUser = await db .insert(users) .values({ email, password: hashedPassword, }) .returning(); await this.sendOtpEmail(email); // new AuthController().verifyOtpHandler(input, ctx) ), resendOtp: publicProcedure .input( z.object({ email: z.string().email(), }) ) .mutation(({ input }) => new AuthController().resendOtpHandler(input.email) ), ... Backend part is done! Frontend All we have to do on the front end is add another step to the sign-up flow for OTP verification. Before that, shadcn-ui has a nice UI component for OTP input. Let's install that: npx shadcn@latest add input-otp The way I approach this is to create a state for the step: const [step, setStep] = useState("email"); So, after the user submits the sign-up form, show them the OTP form. So let's create another form in register-form.tsx. // src/components/modules/auth/register-form.tsx const otpFormSchema = z.object({ otp: z.string().min(6, { message: "Your one-time password must be 6 characters.", }), }); export default function RegisterForm() { const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); // { setStep("otp"); // { setEmail(data.email); // { router.replace("/login"); }, }); const otpForm = useForm({ resolver: zodResolver(otpFormSchema), defaultValues: { otp: "", }, }); const onOtpSubmit = async (data: z.infer) => { try { await verifyOtp({ ...data, email }); } catch (error) { console.error(error); } }; return ( Create an account {step === "email" && ( // sign up form )} {step === "otp" && ( // otp form ( One-Time Password
Published from Publish Studio
In the last two posts from the series "Build a Full-Stack App with tRPC and Next.js App Router", I shared a lot about authentication and authorization. But I left out one crucial part which is verifying user email upon sign up.
We have to make sure the email belongs to that user only and is not fake. For this, we are going to send an OTP to the given email after signup and if the OTP sent by the user is valid, set the email verified to true.
As usual, the project remains same - Finance Tracker [(GitHub repo).
Backend
First, add emailVerified
field to user schema:
// backend/src/modules/user/user.schema.ts
export const users = pgTable("users", {
...
emailVerified: boolean("email_verified").default(false),
...
});
Push changes to the database:
yarn drizzle-kit push
Set up Resend
We are going to use Resend to send emails. One of the reasons I'm recommending Resend is it's a lot easier to set up and no verifications shit like SendGrid. The only thing you need to verify is your domain.
- Sign up for a free Resend account.
- Verify your domain
- Get Resend API key
Modify .env
:
RESEND_API_KEY=your_resend_api_key
EMAIL_FROM="John Doe "
Install Resend Node.js SDK:
yarn add resend
Create src/utils/resend.ts
and export resend
client for use anywhere in our code:
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);
Implement send OTP email
Inside modules/auth/auth.service.ts
, write a function to send an email with OTP. We can generate a random OTP with Math.floor(100000 + Math.random() * 900000)
. Store this OTP in Redis and set your desired expiration time.
// src/modules/auth/auth.service.ts
async sendOtpEmail(email: string) {
const otp = Math.floor(100000 + Math.random() * 900000);
try {
await redis.set(`otp:${email}`, otp, "EX", 5 * 60); // 5 minutes
await resend.emails.send({
from: process.env.EMAIL_FROM!,
to: email,
subject: "OTP to verify your email",
text: `Your OTP is ${otp}. It will expire in 5 minutes.`,
});
return {
success: true,
};
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
}
Now, it's up to you when you want your users to verify their - 1. Right after sign up or 2. Let users experience the product first and limit how long and what they can access the product based on email verification.
For now, let's ask for OTP right after signing up. So put in register
method:
// src/modules/auth/auth.service.ts
...
async register(data: typeof users.$inferInsert) {
...
const newUser = await db
.insert(users)
.values({
email,
password: hashedPassword,
})
.returning();
await this.sendOtpEmail(email); // <---- here
return {
success: true,
user: newUser,
};
...
}
...
Implement Verify OTP
Verifying involves a few steps. Here also you can make a choice - 1. Auto-login user after verifying OTP or 2. Redirect to login. Auto-login might be a better choice in terms of UX.
Steps:
- Check user entered OTP against stored OTP.
- Check if the user exists and set
emailVerified=true
. - Auto-login user.
// src/modules/auth/auth.service.ts
async verifyOtp(email: string, otp: string) {
try {
const savedOtp = await redis.get(`otp:${email}`);
if (savedOtp !== otp) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "OTP expired or invalid",
});
}
await redis.del(`otp:${email}`);
const user = (
await db.select().from(users).where(eq(users.email, email)).limit(1)
)[0];
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await db
.update(users)
.set({ emailVerified: true })
.where(eq(users.id, user.id));
const accessToken = this.createAccessToken(user.id);
const refreshToken = this.createRefreshToken(user.id);
await redis.set(
`refresh_token:${refreshToken}`,
user.id,
"EX",
7 * 24 * 60 * 60 // 7 days
);
await redis.sadd(`refresh_tokens:${user.id}`, refreshToken);
await redis.expire(`refresh_tokens:${user.id}`, 7 * 24 * 60 * 60); // 7 days
await redis.set(
`user:${user.id}`,
JSON.stringify(user),
"EX",
7 * 24 * 60 * 60
); // 7 days
return {
accessToken,
refreshToken,
};
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
}
Now, create verify OTP handler in the controller:
// src/modules/auth/auth.controller.ts
async verifyOtpHandler(data: { email: string; otp: string }, ctx: Context) {
const { email, otp } = data;
const { accessToken, refreshToken } = await super.verifyOtp(email, otp);
const cookies = new Cookies(ctx.req, ctx.res, {
secure: process.env.NODE_ENV === "production",
});
cookies.set("accessToken", accessToken, { ...accessTokenCookieOptions });
cookies.set("refreshToken", refreshToken, {
...refreshTokenCookieOptions,
});
cookies.set("logged_in", "true", { ...accessTokenCookieOptions });
return { success: true };
}
Also, create a resend OTP email handler to give another chance to user in case the previous email failed to deliver:
async resendOtpHandler(email: string) {
return await super.sendOtpEmail(email);
}
As a final step, create routes for these two handlers:
// src/modules/auth/auth.routes.ts
...
verifyOtp: publicProcedure
.input(
z.object({
otp: z.string().length(6),
email: z.string(),
})
)
.mutation(({ input, ctx }) =>
new AuthController().verifyOtpHandler(input, ctx)
),
resendOtp: publicProcedure
.input(
z.object({
email: z.string().email(),
})
)
.mutation(({ input }) =>
new AuthController().resendOtpHandler(input.email)
),
...
Backend part is done!
Frontend
All we have to do on the front end is add another step to the sign-up flow for OTP verification.
Before that, shadcn-ui has a nice UI component for OTP input. Let's install that:
npx shadcn@latest add input-otp
The way I approach this is to create a state for the step:
const [step, setStep] = useState<"email" | "otp">("email");
So, after the user submits the sign-up form, show them the OTP form. So let's create another form in register-form.tsx
.
// src/components/modules/auth/register-form.tsx
const otpFormSchema = z.object({
otp: z.string().min(6, {
message: "Your one-time password must be 6 characters.",
}),
});
export default function RegisterForm() {
const [step, setStep] = useState<"email" | "otp">("email");
const [email, setEmail] = useState(""); // <--- save email after sign up
const { mutateAsync: register, isLoading } = trpc.auth.register.useMutation({
onSuccess: () => {
setStep("otp"); // <--- change step to otp on register success
},
});
const onRegisterSubmit = async (data: z.infer<typeof formSchema>) => {
setEmail(data.email); // <--- set email to use in otp step
...
};
const { mutateAsync: verifyOtp, isLoading: isVerifyingOtp } =
trpc.auth.verifyOtp.useMutation({
onSuccess: () => {
router.replace("/login");
},
});
const otpForm = useForm<z.infer<typeof otpFormSchema>>({
resolver: zodResolver(otpFormSchema),
defaultValues: {
otp: "",
},
});
const onOtpSubmit = async (data: z.infer<typeof otpFormSchema>) => {
try {
await verifyOtp({ ...data, email });
} catch (error) {
console.error(error);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Create an accountCardTitle>
CardHeader>
{step === "email" && (
// sign up form
)}
{step === "otp" && (
// otp form
<Form {...otpForm}>
<form onSubmit={otpForm.handleSubmit(onOtpSubmit)}>
<CardContent className="space-y-4">
<FormField
control={otpForm.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormLabel>One-Time PasswordFormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
InputOTPGroup>
InputOTP>
FormControl>
<FormDescription>
Please enter the one-time password sent to your email.
FormDescription>
<FormMessage />
FormItem>
)}
/>
CardContent>
<CardFooter>
<Button
type="submit"
className="w-full"
disabled={isVerifyingOtp}
>
Submit
Button>
CardFooter>
form>
Form>
)}
Card>
);
}
That's it! Adding email verification with OTP is that simple.
I hope you enjoyed yet another tutorial in Next.js and tRPC series. If so follow for more like this