Implementation Guide for Email Verification in Next.js: Detailed Explanation from Token Validation to Redirecting to the Login Screen

  • nextjs
    nextjs
Published on 2024/05/13

Introduction

Previously, we implemented the process of generating a token and sending an authentication email. This time, we will implement the continuation of that flow. Once we confirm on the accessed screen (via the clicked link) that the token is a previously generated token, we will mark the corresponding email address as email-verified.

Goal for This Article

Account registration -> Check email and click the link -> Email verification process -> Login -> Redirect to settings screen

We will implement this entire flow.

Image from Gyazo

Token Verification

First, we will implement the process that receives the token and verifies it on the server side. Here is a summary of the processing flow:

  • ① Check with the database whether the received token is valid; if it does not exist, return an error.
  • ② From the token and email address pair in VerificationToken, retrieve the email address.
  • ③ Check whether the email address has already been verified; if it has, return a normal (success) result.
  • ④ Check the token’s expiration time; if it has expired, return an error.
  • ⑤ Once all checks are complete, mark the corresponding email address as email-verified and return a normal (success) result.

Basically, token checks occur in two places: ① and ④. However, to handle the case where an already verified user clicks the email link again, we add an early return at step ③.

Create the /actions/new-verification.ts file and add the following code:

new-verification.ts
'use server'

import { getUserByEmail } from '@/data/user'
import { getVerificationTokenByToken } from '@/data/verification-token'
import db from '@/lib/db'

const successMessage =
  'Email verification completed. Please log in from the login screen.'
export const newVerification = async (token: string) => {
  const existingToken = await getVerificationTokenByToken(token)

  if (!existingToken) {
    return { error: 'Token not found.' }
  }

  const existingUser = await getUserByEmail(existingToken.email)

  if (!existingUser) {
    return { error: 'Email address does not exist.' }
  }

  if (existingUser.emailVerified) {
    return { success: successMessage }
  }

  const hasExpired = new Date(existingToken.expires) < new Date()

  if (hasExpired) {
    return { error: 'Token has expired.' }
  }

  try {
    await db.user.update({
      where: { id: existingUser.id },
      data: {
        emailVerified: new Date(),
        email: existingToken.email,
      },
    })
    return { success: successMessage }
  } catch {
    return { error: 'An error occurred.' }
  }
}

Here is an explanation of the code:

    await db.user.update({
      where: { id: existingUser.id },
      data: {
        emailVerified: new Date(),
        email: existingToken.email,
      },
    })

At the same time as setting the current date and time in emailVerified at the moment of verification, we also update email to the email address stored in VerificationToken. If you have been following along with the previous steps, you might feel something is off and wonder, “Why update it if it’s the same email address?”

At this point in the implementation, it will indeed be the same email address, so this update is technically unnecessary. However, in the future, when we support changing the email address and then verifying that new email address, we will need to update the email address at the time of verification. That is why we are handling it here in advance.

(The case of updating an email address will be covered in a separate article.)

Creating the Verification Form

Now that we have the server-side token verification process, we will implement the logic to call this process when the screen is loaded.

Here, we will implement logic that uses useSearchParams to get the token from the URL and useEffect to call the server action. Since both are hooks for client components, we will create a client component.

Introducing a Loading Library

We will implement loading behavior during token verification using a library. This REACT SPINNER library provides various loading styles. This time, we will use BeatLoader.

https://www.davidhu.io/react-spinners/

Usage is simple: you can call the React component directly, and you can also customize the style by passing props such as size and height.

https://github.com/davidhu2000/react-spinners

Install the library with the following command:

npm i react-spinners

Create the /components/auth/new-verification.tsx file and add the following code:

new-verification.tsx
'use client'

import { useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { BeatLoader } from 'react-spinners'
import { newVerification } from '@/actions/new-verification'
import { CardWrapper } from '@/components/auth/card-wrapper'
import { FormError } from '@/components/form-error'
import { FormSuccess } from '@/components/form-success'

export const NewVerificationForm = () => {
  const [error, setError] = useState<string | undefined>()
  const [success, setSuccess] = useState<string | undefined>()

  const searchParams = useSearchParams()

  const token = searchParams.get('token')

  const onSubmit = useCallback(() => {
    if (success || error) return

    if (!token) {
      setError('Token not found.')
      return
    }

    newVerification(token)
      .then((data) => {
        setSuccess(data.success)
        setError(data.error)
      })
      .catch(() => {
        setError('An error occurred.')
      })
  }, [token, success, error])

  useEffect(() => {
    onSubmit()
  }, [onSubmit])

  return (
    <CardWrapper
      headerLabel="Email Verification in Progress"
      buttonLabel="Go to Login Screen"
      buttonHref="/auth/login"
    >
      <div className="flex w-full items-center justify-center">
        {!success && !error && <BeatLoader />}
        <FormSuccess message={success} />
        {!success && <FormError message={error} />}
      </div>
    </CardWrapper>
  )
}

Here is an explanation of the code:

  const searchParams = useSearchParams()

  const token = searchParams.get('token')

We use useSearchParams to get the xxxxx part from the URL ?token=xxxxx.

  const onSubmit = useCallback(() => {
    if (success || error) return

    if (!token) {
      setError('Token not found.')
      return
    }

    newVerification(token)
      .then((data) => {
        setSuccess(data.success)
        setError(data.error)
      })
      .catch(() => {
        setError('An error occurred.')
      })
  }, [token, success, error])

  useEffect(() => {
    onSubmit()
  }, [onSubmit])

As a subsequent step, we execute the server action newVerification we just created, passing the retrieved token as an argument. Based on the result, we store messages for both success and error cases.

      <div className="flex w-full items-center justify-center">
        {!success && !error && <BeatLoader />}
        <FormSuccess message={success} />
        <FormError message={error} />
      </div>

This part returns appropriate messages to the user depending on the verification status. When the screen first loads, neither a success nor an error message exists, so we display the BeatLoader loading animation. Once either message is set, the loading animation disappears.
Then, as with the account registration screen, FormSuccess and FormError display their respective messages when they are set.

  return (
    <CardWrapper
      headerLabel="Email Verification in Progress"
      buttonLabel="Go to Login Screen"
      buttonHref="/auth/login"
    >

After the email verification process is complete, we provide a link so that the user can smoothly move to the login screen.
On this screen, there is nothing for the user to input.

Creating the Token Verification Screen

Finally, we will make the client component we created appear on a page.

In the previous article, we set a link address in the email body:

const confirmLink = `${domain}/auth/new-verification?token=${token}`

To match that, create the /auth/new-verification/page.tsx file and add the following code:

import { NewVerificationForm } from '@/components/auth/new-verification-form'

const NewVerificationPage = () => {
  return <NewVerificationForm />
}

export default NewVerificationPage

Operation Check

Once the screen creation is complete, we will check that everything works. If you have already registered an account, delete the registered account from the database.

npx prisma studio

Select the record you want to delete and click Delete 1 record to delete it.

Image from Gyazo

Account registration -> Check email and click the link -> Email verification process -> Login -> Redirect to settings screen

This is the flow.

Image from Gyazo

After confirming the success pattern, also check the behavior for other patterns.

  • Pattern where the token has expired

If you click the link one hour after the email was sent, the following message will appear:

Image from Gyazo

  • Token not found

When a malicious user directly accesses the email verification screen (for example, by directly entering http://localhost:3000/auth/new-verification in the browser)

Image from Gyazo

Conclusion

That concludes the email verification feature introduced since the previous article. The processing itself is probably not that complex, but you need to consider various states and change the messages accordingly, so it ends up being a process with a fair amount of code. In real services, there are often screens that allow users to resend the email in case they forgot to complete email verification. In this article, we substituted that functionality with the login screen, but if there is an opportunity, I would also like to introduce an implementation of a dedicated email-resend screen.

Recommended Article

https://shinagawa-web.com/en/blogs/nextjs-password-reset-email-sending

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form