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

Feb 3, 2025 - 20:34
 0
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:

// 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.

  1. Sign up for a free Resend account.
  2. Verify your domain
  3. 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:

  1. Check user entered OTP against stored OTP.
  2. Check if the user exists and set emailVerified=true.
  3. 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