Email Verification System in Next.js and tRPC with Resend
Rakesh Potnuru
written by Rakesh Potnuru4 min read

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.

email verification

As usual, the project remains same - Finance Tracker [(GitHub repo).

Backend

First, add emailVerified field to user schema:

ts
1// backend/src/modules/user/user.schema.ts 2 3export const users = pgTable("users", { 4... 5 emailVerified: boolean("email_verified").default(false), 6... 7});

Push changes to the database:

shell
1yarn 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.

  1. Sign up for a free Resend account.
  2. Verify your domain
  3. Get Resend API key

Modify .env:

shell
1RESEND_API_KEY=your_resend_api_key 2EMAIL_FROM="John Doe <noreply@example.com>"

Install Resend Node.js SDK:

shell
1yarn add resend

Create src/utils/resend.ts and export resend client for use anywhere in our code:

ts
1import { Resend } from "resend"; 2 3export 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.

ts
1// src/modules/auth/auth.service.ts 2 3 async sendOtpEmail(email: string) { 4 const otp = Math.floor(100000 + Math.random() * 900000); 5 6 try { 7 await redis.set(`otp:${email}`, otp, "EX", 5 * 60); // 5 minutes 8 9 await resend.emails.send({ 10 from: process.env.EMAIL_FROM!, 11 to: email, 12 subject: "OTP to verify your email", 13 text: `Your OTP is ${otp}. It will expire in 5 minutes.`, 14 }); 15 16 return { 17 success: true, 18 }; 19 } catch (error) { 20 console.log(error); 21 22 throw new TRPCError({ 23 code: "INTERNAL_SERVER_ERROR", 24 message: "Something went wrong", 25 }); 26 } 27 }

Now, it's up to you when you want your users to verify their email - 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:

ts
1// src/modules/auth/auth.service.ts 2... 3 async register(data: typeof users.$inferInsert) { 4... 5 const newUser = await db 6 .insert(users) 7 .values({ 8 email, 9 password: hashedPassword, 10 }) 11 .returning(); 12 13 await this.sendOtpEmail(email); // <---- here 14 15 return { 16 success: true, 17 user: newUser, 18 }; 19... 20 } 21...

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:

  1. Check user entered OTP against stored OTP.
  2. Check if the user exists and set emailVerified=true.
  3. Auto-login user.
ts
1// src/modules/auth/auth.service.ts 2 3 async verifyOtp(email: string, otp: string) { 4 try { 5 const savedOtp = await redis.get(`otp:${email}`); 6 7 if (savedOtp !== otp) { 8 throw new TRPCError({ 9 code: "UNAUTHORIZED", 10 message: "OTP expired or invalid", 11 }); 12 } 13 14 await redis.del(`otp:${email}`); 15 16 const user = ( 17 await db.select().from(users).where(eq(users.email, email)).limit(1) 18 )[0]; 19 20 if (!user) { 21 throw new TRPCError({ 22 code: "NOT_FOUND", 23 message: "User not found", 24 }); 25 } 26 27 await db 28 .update(users) 29 .set({ emailVerified: true }) 30 .where(eq(users.id, user.id)); 31 32 const accessToken = this.createAccessToken(user.id); 33 const refreshToken = this.createRefreshToken(user.id); 34 35 await redis.set( 36 `refresh_token:${refreshToken}`, 37 user.id, 38 "EX", 39 7 * 24 * 60 * 60 // 7 days 40 ); 41 42 await redis.sadd(`refresh_tokens:${user.id}`, refreshToken); 43 await redis.expire(`refresh_tokens:${user.id}`, 7 * 24 * 60 * 60); // 7 days 44 45 await redis.set( 46 `user:${user.id}`, 47 JSON.stringify(user), 48 "EX", 49 7 * 24 * 60 * 60 50 ); // 7 days 51 52 return { 53 accessToken, 54 refreshToken, 55 }; 56 } catch (error) { 57 console.log(error); 58 59 throw new TRPCError({ 60 code: "INTERNAL_SERVER_ERROR", 61 message: "Something went wrong", 62 }); 63 } 64 }

Now, create verify OTP handler in the controller:

ts
1// src/modules/auth/auth.controller.ts 2 3 async verifyOtpHandler(data: { email: string; otp: string }, ctx: Context) { 4 const { email, otp } = data; 5 6 const { accessToken, refreshToken } = await super.verifyOtp(email, otp); 7 8 const cookies = new Cookies(ctx.req, ctx.res, { 9 secure: process.env.NODE_ENV === "production", 10 }); 11 cookies.set("accessToken", accessToken, { ...accessTokenCookieOptions }); 12 cookies.set("refreshToken", refreshToken, { 13 ...refreshTokenCookieOptions, 14 }); 15 cookies.set("logged_in", "true", { ...accessTokenCookieOptions }); 16 17 return { success: true }; 18 }

Also, create a resend OTP email handler to give another chance to user in case the previous email failed to deliver:

ts
1 async resendOtpHandler(email: string) { 2 return await super.sendOtpEmail(email); 3 }

As a final step, create routes for these two handlers:

ts
1// src/modules/auth/auth.routes.ts 2 3... 4 verifyOtp: publicProcedure 5 .input( 6 z.object({ 7 otp: z.string().length(6), 8 email: z.string(), 9 }) 10 ) 11 .mutation(({ input, ctx }) => 12 new AuthController().verifyOtpHandler(input, ctx) 13 ), 14 15 resendOtp: publicProcedure 16 .input( 17 z.object({ 18 email: z.string().email(), 19 }) 20 ) 21 .mutation(({ input }) => 22 new AuthController().resendOtpHandler(input.email) 23 ), 24...

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:

shell
1npx shadcn@latest add input-otp

The way I approach this is to create a state for the step:

tsx
1const [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.

tsx
1// src/components/modules/auth/register-form.tsx 2 3const otpFormSchema = z.object({ 4 otp: z.string().min(6, { 5 message: "Your one-time password must be 6 characters.", 6 }), 7}); 8 9export default function RegisterForm() { 10 const [step, setStep] = useState<"email" | "otp">("email"); 11 const [email, setEmail] = useState(""); // <--- save email after sign up 12 13 const { mutateAsync: register, isLoading } = trpc.auth.register.useMutation({ 14 onSuccess: () => { 15 setStep("otp"); // <--- change step to otp on register success 16 }, 17 }); 18 19 const onRegisterSubmit = async (data: z.infer<typeof formSchema>) => { 20 setEmail(data.email); // <--- set email to use in otp step 21 ... 22 }; 23 24 const { mutateAsync: verifyOtp, isLoading: isVerifyingOtp } = 25 trpc.auth.verifyOtp.useMutation({ 26 onSuccess: () => { 27 router.replace("/login"); 28 }, 29 }); 30 31 const otpForm = useForm<z.infer<typeof otpFormSchema>>({ 32 resolver: zodResolver(otpFormSchema), 33 defaultValues: { 34 otp: "", 35 }, 36 }); 37 38 const onOtpSubmit = async (data: z.infer<typeof otpFormSchema>) => { 39 try { 40 await verifyOtp({ ...data, email }); 41 } catch (error) { 42 console.error(error); 43 } 44 }; 45 46 return ( 47 <Card> 48 <CardHeader> 49 <CardTitle>Create an account</CardTitle> 50 </CardHeader> 51 {step === "email" && ( 52 // sign up form 53 )} 54 {step === "otp" && ( 55 // otp form 56 <Form {...otpForm}> 57 <form onSubmit={otpForm.handleSubmit(onOtpSubmit)}> 58 <CardContent className="space-y-4"> 59 <FormField 60 control={otpForm.control} 61 name="otp" 62 render={({ field }) => ( 63 <FormItem> 64 <FormLabel>One-Time Password</FormLabel> 65 <FormControl> 66 <InputOTP maxLength={6} {...field}> 67 <InputOTPGroup> 68 <InputOTPSlot index={0} /> 69 <InputOTPSlot index={1} /> 70 <InputOTPSlot index={2} /> 71 <InputOTPSlot index={3} /> 72 <InputOTPSlot index={4} /> 73 <InputOTPSlot index={5} /> 74 </InputOTPGroup> 75 </InputOTP> 76 </FormControl> 77 <FormDescription> 78 Please enter the one-time password sent to your email. 79 </FormDescription> 80 <FormMessage /> 81 </FormItem> 82 )} 83 /> 84 </CardContent> 85 <CardFooter> 86 <Button 87 type="submit" 88 className="w-full" 89 disabled={isVerifyingOtp} 90 > 91 Submit 92 </Button> 93 </CardFooter> 94 </form> 95 </Form> 96 )} 97 </Card> 98 ); 99}

That's it! Adding email verification with OTP is that simple.


I hope you enjoyed yet another tutorial in Next.js and tRPC series.

Socials


Interested in
working
with me?

Let's Connect

© 2021 - 2025 itsrakesh. v2.