Implementation Guide for Email Verification in Next.js: From Account Registration to Token Validation
Introduction
In the previous chapters, we implemented account registration, login, and logout using email and password with Next.js and Auth.js. While we now have the minimum authentication features, to operate a web service in practice we need to anticipate various cases and implement additional functionality.
- Verify that the registered email address is correct (so we can use it for various notifications and announcements)
- Password reset (for users who forget their password)
- Issue one-time tokens to strengthen security with two-factor authentication
In this and the next article, we will implement the first of these: “Verifying that the registered email address is correct” (email verification).
Designing Email Verification
Up to now, we have controlled which pages can be accessed by logged-out and logged-in users. We used Auth.js to determine whether a user is logged in or not, and mainly used Next.js middleware for access control and redirects.
This time, we will determine whether an email address is verified or unverified based on user information retrieved from the database, and implement logic that sends emails or navigates to the appropriate screen depending on that result.
- Account registration flow
When an account is registered, we send an email to the registered email address. The user clicks the link in the email and is taken to a newly created “Token Verification Screen.”
If the token is still within its validity period, email verification is completed.
- Login flow
When logging in, we check whether email verification has been completed. If the user is already verified, we navigate to the settings screen as before. If the user has not yet verified their email, we resend the verification email and prompt them to complete email verification first.
Goal of This Article
We will implement the process that sends a verification email when an account is registered, or when a user whose email is not yet verified attempts to log in.
The “Token Verification Screen” that actually performs verification using the email will be implemented in the next article.
Email Delivery Services
The subject, recipient, and body of the email will be defined inside a Next.js server action, but the actual sending of the email will be handled by an email delivery service.
- Amazon Simple Email Service
- Sendgrid
- Resend
- Mailgun
There are many services that handle email delivery. Here are some simple criteria for choosing an email delivery service.
-
Deliverability
Whether the email actually reaches the recipient’s inbox is the most important factor. This is especially critical for transactional emails and important notifications. Services like Amazon SES are known for high deliverability. -
Scalability
Whether the service can handle future growth. Even small projects may eventually need to send large volumes of email. Services like Amazon SES and SendGrid can reliably send millions of emails, making them strong candidates when considering scalability. -
Cost
Costs vary depending on the volume of emails sent, so pricing is also important. When sending large volumes, the per-email cost has a big impact. Services with free tiers or low-cost plans offer good cost performance. -
API features and ease of use
For developers, the usability of the API, the quality of the documentation, and ease of integration are also important. -
Support and documentation
You should also consider the quality of support when issues arise, and how complete the documentation is for APIs and configuration. It’s worth considering services that have thorough documentation and well-regarded support.
In this article, we will proceed with an implementation using Sendgrid.
Domain Authentication for Email Delivery Services
Due to spam issues, checks on the sender are becoming stricter. Domain authentication for the sending domain is required by most email delivery services. If you already have a domain for sending email, you can simply add a few DNS records for that domain. If you don’t have a domain, you’ll need to obtain one using services like AWS Route53 or Onamae.com.
Even for .com, you can usually find cheaper domains for around 1,000–2,000 yen per year.
Implementation Flow
Now that we’ve covered the basics of email delivery services, let’s move on to the actual implementation. We’ll proceed in the following steps:
- ① Enable storing tokens in the database
- ② Implement token creation
- ③ Implement email sending using the created token
- ④ Integrate into account registration and login flows
- ⑤ Verify behavior
① Enabling Token Storage in the Database
Create a new model so that issued tokens can be stored in the database. Add the following to your existing schema.prisma.
+ model VerificationToken {
+ id String @id @default(cuid())
+ email String
+ token String @unique
+ expires DateTime
+ @@unique([email, token])
+ }
In addition to the token, we also store which email address it belongs to and when it expires.
Next, add a key to the User model to indicate whether email verification has been completed. Add emailVerified as a date type. We use a date type so we can see when the email was verified.
model User {
id String @id @default(cuid())
name String
email String @unique
+ emailVerified DateTime?
password String
}
After writing the code, generate the types for use in TypeScript with the following command:
$ npx prisma generate
Reflect the schema definition in the database so that a VerificationToken table is created.
$ npx prisma db push
Confirm that the table has been created in the database.
$ npx prisma studio
② Implementing Token Creation
First, install uuid, which we’ll use when generating tokens.
npm i uuid
npm i --save-dev @types/uuid
This is a convenient library for generating random strings.
Create a lib/tokens.ts file and add the following code:
import { v4 as uuidv4 } from 'uuid'
import db from '@/lib/db'
export const generateVerificationToken = async (email: string) => {
const token = uuidv4()
const expires = new Date(new Date().getTime() + 60 * 60 * 1000)
//TDOO: If a token already exists, delete it
const verificationToken = await db.verificationToken.create({
data: {
email,
token,
expires,
},
})
return verificationToken
}
We set the expiration time to one hour from when the token is created.
60(seconds) * 60(minutes) * 1000(milliseconds)
Create a data/verification-token.ts file and add the following code:
import db from '@/lib/db'
export const getVerificationTokenByToken = async (token: string) => {
try {
const verificationToken = await db.verificationToken.findUnique({
where: { token },
})
return verificationToken
} catch {
return null
}
}
export const getVerificationTokenByEmail = async (email: string) => {
try {
const verificationToken = await db.verificationToken.findFirst({
where: { email },
})
return verificationToken
} catch {
return null
}
}
Both functions retrieve tokens stored in the database, but one takes email and the other takes token as an argument, so we provide two functions. If a token exists, it is returned; if not, null is returned.
Go back to the lib/tokens.ts file and delete any existing token if one is found. This ensures that when multiple verification tokens are issued, only the latest token remains valid.
import { v4 as uuidv4 } from 'uuid'
+ import { getVerificationTokenByEmail } from '@/data/verification-token'
import db from '@/lib/db'
export const generateVerificationToken = async (email: string) => {
const token = uuidv4()
const expires = new Date(new Date().getTime() + 60 * 60 * 1000)
+ const existingToken = await getVerificationTokenByEmail(email)
+ if (existingToken) {
+ await db.verificationToken.delete({
+ where: {
+ id: existingToken.id,
+ },
+ })
+ }
const verificationToken = await db.verificationToken.create({
data: {
email,
token,
expires,
},
})
return verificationToken
}
③ Implementing Email Sending Using the Created Token
First, set up Sendgrid.
Follow the “Managing API Keys” section in this guide to obtain an API key and add it to your .env file.
SENDGRID_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Sendgrid provides a library for node.js, which makes implementing email sending smoother.
npm i @sendgrid/mail
You can refer to this for how to use the above library:
Create a lib/mail.ts file and implement the email sending logic.
import sendgrid from '@sendgrid/mail'
sendgrid.setApiKey(process.env.SENDGRID_API_KEY as string)
const domain = process.env.NEXT_PUBLIC_APP_URL
export const sendVerificationEmail = async (email: string, token: string) => {
const confirmLink = `${domain}/auth/new-verification?token=${token}`
try {
await sendgrid.send({
from: 'test-taro@shinagawa-web.com',
to: email,
subject: 'Confirm your email address',
html: `<p>Please click the link below to verify your email address.<br><a href="${confirmLink}">Click here</a></p>`,
})
return { success: true }
} catch (err) {
return { success: false }
}
}
Here’s an explanation of the code:
const domain = process.env.NEXT_PUBLIC_APP_URL
(omitted in part)
export const sendVerificationEmail = async (email: string, token: string) => {
The function takes the email address and token as arguments.
By including the token in this link, the server can validate the token when the link is accessed.
const confirmLink = `${domain}/auth/new-verification?token=${token}`
We generate the URL for the link that the user will click when they open the email.
Since we are currently developing locally, add the following to your .env file:
NEXT_PUBLIC_APP_URL="http://localhost:3000"
Later, when deploying to Vercel or AWS for production use, we can define the domain via environment variables.
await sendgrid.send({
from: 'test-taro@shinagawa-web.com',
to: email,
subject: 'Confirm your email address',
html: `<p>Please click the link below to verify your email address.<br><a href="${confirmLink}">Click here</a></p>`,
})
We define the sender address with from. This must use a domain that has been authenticated with your email delivery service. In my case, I use my company’s domain.
We define the email body in HTML format using the html key. We use an anchor tag to set the link.
④ Integrating into Account Registration and Login Flows
Integrate the token generation and email sending logic into the account registration process.
+ import { generateVerificationToken } from '@/lib/tokens'
+ import { sendVerificationEmail } from '@/lib/mail'
(omitted in part)
try {
await db.user.create({
data: {
name,
email,
password: hashedPassword,
},
})
+ const verificationToken = await generateVerificationToken(email)
+ const sendResult = await sendVerificationEmail(
+ verificationToken.email,
+ verificationToken.token,
+ )
+ if (sendResult.success) {
+ return {
+ success:
+ 'We have sent a confirmation email. Please click the link in the email to activate your account.',
+ }
+ } else {
+ return { error: 'Failed to send confirmation email.' }
+ }
- return { success: 'Account registered.' }
} catch (e) {
After registering the account in the database, we add the token generation process.
Once the token is generated, we pass that token and the email address to sendVerificationEmail to send the email. If the email is sent successfully, we display the message “We have sent a confirmation email. Please click the link in the email to activate your account.” on the account registration screen to prompt the user to check their email.
Since we’re now displaying a relatively long message, we’ll adjust the CSS for the input form.
return (
<div className="flex items-center gap-x-2 rounded-md bg-emerald-500/15 p-3 text-sm text-emerald-500">
- <CheckCircledIcon className="size-4" />
+ <CheckCircledIcon className="size-4 shrink-0" />
<p>{message}</p>
</div>
)
Next, we integrate logic into the login process so that if the account’s email is not yet verified, a new verification email is sent.
+ import { getUserByEmail } from '@/data/user'
+ import { sendVerificationEmail } from '@/lib/mail'
+ import { generateVerificationToken } from '@/lib/tokens'
(omitted in part)
if (!validatedFields.success) {
return { error: 'Please correct the input.' }
}
const { email, password } = validatedFields.data
+ const existingUser = await getUserByEmail(email)
+ if (!existingUser || !existingUser.email || !existingUser.password) {
+ return { error: 'The email address or password is incorrect.' }
+ }
+ if (!existingUser.emailVerified) {
+ const verificationToken = await generateVerificationToken(
+ existingUser.email,
+ )
+ const sendResult = await sendVerificationEmail(
+ verificationToken.email,
+ verificationToken.token,
+ )
+ if (sendResult.success) {
+ return {
+ success:
+ 'Your email address is not verified. We have resent the verification link to your email.',
+ }
+ } else {
+ return { error: 'Failed to send confirmation email.' }
+ }
+ }
try {
await signIn('credentials', {
Before calling signIn, which actually performs the login, we check whether email verification has been completed. Before that, we also need to check whether the email address used for login exists, so we use getUserByEmail.
If the email address does not exist, we could display a message like “This email address does not exist.” on the input screen, but that would unnecessarily reveal information to a malicious user. For that reason, we use the message “The email address or password is incorrect.”
if (!existingUser.emailVerified) {
const verificationToken = await generateVerificationToken(
existingUser.email,
)
const sendResult = await sendVerificationEmail(
verificationToken.email,
verificationToken.token,
)
if (sendResult.success) {
return {
success:
'Your email address is not verified. We have resent the verification link to your email.',
}
} else {
return { error: 'Failed to send confirmation email.' }
}
}
Next, we check whether the email is verified. If emailVerified is not set, we treat the account as unverified and generate a token and send an email. If emailVerified already has a date set (we haven’t implemented this part yet), we do nothing special and proceed to signIn.
Up to now, the login process immediately redirected to the settings screen on success, so we didn’t display a success message. Now that we need to show a success message when the email is resent, we’ll also update the login form.
+ import { FormSuccess } from '../form-success'
const [error, setError] = useState<string | undefined>('')
+ const [success, setSuccess] = useState<string | undefined>('')
const [isPending, setTransition] = useTransition()
(omitted in part)
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
setError('')
+ setSuccess('')
setTransition(async () => {
try {
const response = await login(values)
- if (response && response.error) {
- setError(response.error)
- }
+ setError(response?.error)
+ setSuccess(response?.success)
} catch (e) {
setError('An error has occurred.')
}
})
}
(omitted in part)
<FormError message={error} />
+ <FormSuccess message={success} />
<Button type="submit" className="w-full" disabled={isPending}>
Log in
</Button>
We now manage the success message with useState and display it using FormSuccess.
Verifying Behavior
First, on the account registration screen, register an account using an email address that actually exists.
After registration, an email is sent and a message is displayed on the screen.
Check that the email has actually arrived. Also hover over the link and confirm that it points to the expected address.
localhost:3000/auth/new-verification?token=xxxxxxxxxx
If the address is not as expected, check whether NEXT_PUBLIC_APP_URL is correctly set in .env, etc. If you want to test again because of an implementation mistake, it’s a good idea to delete the created account in Prisma Studio before retesting.
Click the link in the email. You will probably be redirected to the login screen. This is due to Next.js middleware. With the current settings, /auth/new-verification is only accessible after login, so when a logged-out user clicks the link, they are redirected to the login screen. To allow access to this route even when logged out, update the /route.ts file.
export const authRoutes: string[] = [
'/auth/login',
'/auth/register',
+ '/auth/new-verification',
]
After making this change, restart Next.js and click the link in the email again. This time, a 404 page will be displayed, and if you check the URL, it should be the expected address.
You can see the generated token in ?token=xxx, and you can confirm in Prisma Studio that the token stored in the database matches.
Next, verify the login behavior. Log in using the email address you just registered. Since email verification is not yet complete, a new verification email will be sent.
That completes the behavior verification for what we implemented in this article.
Conclusion
In this article, we implemented token generation and email sending, and integrated them into the existing account registration and login flows. In the next article, we will implement the mechanism on the page that currently returns 404 to receive the token, check whether it matches the generated token, and whether it is still within its validity period.
In addition to the previous logged-out and logged-in states, we now have verified and unverified email states, increasing the number of states to manage. As a result, there are more considerations for access control and server actions. In real-world projects, it’s helpful to use flow diagrams and similar tools to organize what actions should be taken in each state before starting implementation.
Next Article
We introduce the “Token Verification Screen,” which performs the actual verification using email, in the following article:
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Implementation Guide for Email Verification in Next.js: Detailed Explanation from Token Validation to Redirecting to the Login Screen
2024/05/13Implementing an Account Registration Screen and Validation Using Next.js and React Hook Form
2024/01/22Robust Authorization Design for GraphQL and REST APIs: Best Practices for RBAC, ABAC, and OAuth 2.0
2024/05/13Chat App (with Image/PDF Sending and Video Call Features)
2024/07/15Practical Component Design Guide with React × Tailwind CSS × Emotion: The Optimal Approach to Design Systems, State Management, and Reusability
2024/11/22Management Dashboard Features (Graph Display, Data Import)
2024/06/02Let's Build an Article Posting API with NestJS ─ Basics of Introducing Prisma and Implementing CRUD
2025/04/12Improving the Reliability of a NestJS App ─ Logging, Error Handling, and Testing Strategy
2024/09/11








