DEV Community

Cover image for Email Verification System in Next.js and tRPC with Resend
Rakesh Potnuru
Rakesh Potnuru

Posted on • Originally published at itsrakesh.com

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),
...
});
Enter fullscreen mode Exit fullscreen mode

Push changes to the database:

yarn drizzle-kit push
Enter fullscreen mode Exit fullscreen mode

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 <noreply@example.com>"
Enter fullscreen mode Exit fullscreen mode

Install Resend Node.js SDK:

yarn add resend
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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",
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

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,
      };
...
  }
...
Enter fullscreen mode Exit fullscreen mode

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",
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

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 };
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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)
    ),
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

const [step, setStep] = useState<"email" | "otp">("email");
Enter fullscreen mode Exit fullscreen mode

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 account</CardTitle>
      </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 Password</FormLabel>
                    <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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Socials

Top comments (0)