Person thinking about their correct password

How to Create a Password Reset Flow in Next.js?

One of the most annoying experiences for a user is to forget their password.

To make it even more aggravating, some reset password flows are so badly designed that the user would just think of not using the application again.

Therefore, it is really crucial to have a solid UX for the password reset process. This is one of the problems that I encountered while building Libertas, an online discussion platform, built with Next.js.

Hence, I surfed on the internet and got inspired by this article.

Here’s how I created a password reset flow in Next.js and how you can do it too.

Table of Contents

But first…

How a Password Reset Flow should look like?

Ideally, the password reset flow should have 5 steps that are pretty straightforward. They are listed below:

Sign in page on npm website
Password email sent screenshot on npm website
Reset password mail for npm

4. Type and confirm the new password

Change password page on npm website

5. Authenticate with the new password

Sign in page on npm website

For the final step, some would argue that why not just get the user automatically signed in after confirming their new password. But maybe this is largely avoided just to add an additional layer of security.

Steps to implement password reset flow in Next.js

1. Create a Next.js app

Type the following command:

npx create-next-app@latest

2. Create a Login page

Login page screenshot on Libertas website

Code:

"use client";
import ErrorText from "@/components/errorComponents/ErrorText";
import LoadingButton from "@/components/pageComponents/LoadingButton";
import { GlobalContext } from "@/services/globalContext";
import { Button, Stack } from "@mui/material";
import Link from "next/link";
import { useContext, useEffect, useState } from "react";
import TextInput from "../../components/formComponents/TextInput";
import { useRouter } from "next/navigation";
import { colors } from "@/theme/colors";
import PasswordInput from "@/components/formComponents/PasswordInput";
import TitleText from "@/components/pageComponents/TitleText";

const Login = () => {
const { loading, login, loginError, user } = useContext(GlobalContext);

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

const router = useRouter();

const submitHandler = (e) => {
e.preventDefault();

// console.log(email, password);
login(email, password);
};

useEffect(() => {
if (user) {
router.push("/feed");
}
}, [user]);

useEffect(() => {
document.title = `Login | Libertas`;
}, []);

return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div>
<TitleText title="Login" text="Sign in to your account on Libertas" />

<form onSubmit={submitHandler}>
<Stack spacing={2} style={{ textAlign: "center" }}>
{loginError && <ErrorText message={loginError} />}
<TextInput
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required={true}
/>
<PasswordInput
password={password}
handlePassword={(e) => setPassword(e.target.value)}
showCapsLockOnMessage={true}
/>
<Stack alignItems="flex-end" style={{ marginTop: 8 }}>
<Link
href="/recover-password"
style={{
fontSize: "0.875rem",
color: "#000",
textDecoration: "underline",
}}
>
Forgot password?
</Link>
</Stack>
<Button
variant="contained"
type="submit"
style={{
textTransform: "capitalize",
backgroundColor: colors.button.background,
fontWeight: "600",
borderRadius: "0rem",
marginTop: 30,
}}
>
Login
{loading && (
<div style={{ marginLeft: "0.6rem" }}>
<LoadingButton />
</div>
)}
</Button>
<p style={{ textAlign: "center", fontSize: "0.875rem" }}>
Don&apos;t have an account?{" "}
<Link
href="/sign-up"
style={{ color: "#000", textDecoration: "underline" }}
>
Sign up
</Link>
</p>
</Stack>
</form>
</div>
</div>
);
};

export default Login;

TextInput component:

import { TextField } from "@mui/material";
import React from "react";

const TextInput = ({
type,
placeholder,
value,
onChange,
required,
nameOfInput,
}) => {
return (
<TextField
type={type}
label={placeholder}
value={value}
onChange={onChange}
fullWidth
required={required ? required : false}
size="small"
name={nameOfInput}
/>
);
};
export default TextInput;

PasswordInput component:

import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import { IconButton, InputAdornment, Stack, TextField } from "@mui/material";
import { useState } from "react";

const PasswordInput = ({ password, handlePassword, showCapsLockOnMessage }) => {
const [showPassword, setShowPassword] = useState(false);
const [capsLockOnMessage, setCapsLockOnMessage] = useState("");

const handleClickShowPassword = () => {
setShowPassword(!showPassword);
};

// detect if caps lock is on
const handleKeyUp = (e) => {
const capsLockOn = e.getModifierState("CapsLock");

if (capsLockOn) {
setCapsLockOnMessage("Caps Lock is on");
} else {
setCapsLockOnMessage("");
}
};

return (
<Stack alignItems="flex-start" spacing={1}>
<TextField
size="small"
type={showPassword ? "text" : "password"}
label="Password"
value={password}
onChange={handlePassword}
required={true}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
fullWidth
onKeyUp={handleKeyUp}
/>
{showCapsLockOnMessage && !!capsLockOnMessage && (
<Stack direction="row" alignItems="center" spacing={1}>
<FontAwesomeIcon icon={faCircleInfo} />
<p style={{ fontSize: "0.875rem" }}>{capsLockOnMessage}</p>
</Stack>
)}
</Stack>
);
};

export default PasswordInput;

Then an email will pop up on the user’s Gmail account.

<Link
href="/recover-password"
style={{
fontSize: "0.875rem",
color: "#000",
textDecoration: "underline",
}}
>
Forgot password?
</Link>

I have used a <Link> component of Next.js, which on clicking, goes to “/recover-password” page.

This is how it looks like

4. Create a Forgot Password page

Recover password page on Libertas containing an email input box and a "send password email" button

Code:

"use client";
import ErrorText from "@/components/errorComponents/ErrorText";
import TextInput from "@/components/formComponents/TextInput";
import LoadingButton from "@/components/pageComponents/LoadingButton";
import TitleText from "@/components/pageComponents/TitleText";
import { GlobalContext } from "@/services/globalContext";
import { colors } from "@/theme/colors";
import { Button, Stack } from "@mui/material";
import { useContext, useEffect, useState } from "react";

const RecoverPassword = () => {
const { loading, passwordRecoveryEmailError, sendPasswordRecoveryEmail } =
useContext(GlobalContext);

const [emailOrUsername, setEmailOrUsername] = useState("");

const submitHandler = (e) => {
e.preventDefault();

sendPasswordRecoveryEmail(emailOrUsername);
};

const isButtonDisabled = !emailOrUsername;

return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div>
<TitleText
title="Recover Password"
text="It's okay, we have got this!"
/>

<form onSubmit={submitHandler}>
<Stack spacing={2} style={{ textAlign: "center" }}>
{passwordRecoveryEmailError && (
<ErrorText message={passwordRecoveryEmailError} />
)}
<TextInput
type="text"
placeholder="Email or username"
value={emailOrUsername}
onChange={(e) => setEmailOrUsername(e.target.value)}
required={true}
/>
<Button
variant="contained"
type="submit"
style={{
textTransform: "none",
backgroundColor: colors.button.background,
fontWeight: "600",
borderRadius: "0rem",
}}
sx={{
"&.Mui-disabled": {
color: "grey",
},
}}
disabled={isButtonDisabled}
>
Send me a reset password email
{loading && (
<div style={{ marginLeft: "0.6rem" }}>
<LoadingButton />
</div>
)}
</Button>
</Stack>
</form>
</div>
</div>
);
};

export default RecoverPassword;

sendPasswordRecoveryEmail function is in the next step.

5. Send the “Change Password” mail to the user

To send the email to the user, I used nodemailer package in my Node.js/Express backend.

a. Install nodemailer

With npm

npm i nodemailer

With yarn

yarn add nodemailer

b. Setup nodemailer

I have used “Gmail” as the service, so before creating the controller to send password recovery email to the user in their Gmail account, I had to setup nodemailer with the help of this page.

c. Create the controller to send email to the user

const sendPasswordRecoveryEmail = asyncHandler(async (req, res, next) => {
const { emailOrUsername } = req.body;

if (!emailOrUsername) {
res.status(400);
return next(new Error("Field is required"));
}

// Find the user
let userAvailable;
if (emailOrUsername.includes("@")) {
userAvailable = await UserModel.findOne({ email: emailOrUsername });
} else {
userAvailable = await UserModel.findOne({ username: emailOrUsername });
}

if (!userAvailable) {
res.status(400);
return next(new Error("User is not found"));
}

const html = `
<p>Hi, ${userAvailable.name},</p>
<p>Here's your password recovery link</p>
<a href="https://libertas-vert.vercel.app/reset-password/${userAvailable._id}">Reset password here</a>
<p>Best regards, Libertas</p>
`;

const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.GOOGLE_ACCOUNT_USER,
pass: process.env.GOOGLE_ACCOUNT_PASS,
},
});

if (userAvailable) {
// sending email with nodemailer
const info = await transporter.sendMail({
from: '"Libertas" <libertas.discussion@gmail.com>', // sender address
to: userAvailable.email,
subject: `Reset your Libertas password`, // Subject line
html: html, // html body
});

res.status(201).json({
success: true,
message: "Password recovery email has been sent succesfully",
id: userAvailable._id,
email: userAvailable.email,
info: info,
});
} else {
res.status(400);
return next(new Error("Something went wrong!"));
}
});

In the above code:

  • I am allowing the user to type either their “username” or “email”.
const { emailOrUsername } = req.body;
  • I am checking if the user with that email or username exists in the database. If not, I throw an error.
// Find the user
let userAvailable;
if (emailOrUsername.includes("@")) {
userAvailable = await UserModel.findOne({ email: emailOrUsername });
} else {
userAvailable = await UserModel.findOne({ username: emailOrUsername });
}

if (!userAvailable) {
res.status(400);
return next(new Error("User is not found"));
}
  • I have created a simple html markup that will show on the email that goes into the user’s Gmail account.
const html = `
<p>Hi, ${userAvailable.name},</p>
<p>Here's your password recovery link</p>
<a href="https://libertas-vert.vercel.app/reset-password/${userAvailable._id}">Reset password here</a>
<p>Best regards, Libertas</p>
`;
  • Next, I have configured the nodemailer transporter. I have specificed “gmail” as the “service” and used my Google username and password which was setup with this.
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.GOOGLE_ACCOUNT_USER,
pass: process.env.GOOGLE_ACCOUNT_PASS,
},
});j
  • If the user is available in the database, I send the mail to their Gmail account.
if (userAvailable) {
// sending email with nodemailer
const info = await transporter.sendMail({
from: '"Libertas" <libertas.discussion@gmail.com>', // sender address
to: userAvailable.email,
subject: `Reset your Libertas password`, // Subject line
html: html, // html body
});

res.status(201).json({
success: true,
message: "Password recovery email has been sent succesfully",
id: userAvailable._id,
email: userAvailable.email,
info: info,
});
} else {
res.status(400);
return next(new Error("Something went wrong!"));
}

About half of the code above is not necessary as typing only email will work for most users, but in Libertas, I gave the option to find the user by both their “username” and “email”.

d. The mail in action

If nodemailer works successfully, an email will pop up on the user’s Gmail account.

I haven’t created an email template yet, hence the plain text

It is a good practice to let the user know about what happened when they clicked on the “Send me a password recovery email” button. 

This way they won’t be left hanging.

I created this feedback page, letting the user know that an email has been send to their account

Code for above “feedback” page is:

"use client";
import { Stack } from "@mui/material";
import Link from "next/link";
import { useEffect } from "react";

const PasswordRecoveryEmailSent = () => {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div>
<Stack
alignItems="center"
spacing={2}
style={{ marginBottom: "2rem", textAlign: "center" }}
>
<h1>Check your email</h1>
<p>
An email has been sent to your email address to reset your password.
</p>
<Link
href="/login"
style={{
fontSize: "0.875rem",
color: "#000",
textDecoration: "underline",
}}
>
Back to login
</Link>
</Stack>
</div>
</div>
);
};

export default PasswordRecoveryEmailSent;

6. Create a “Reset Password” page

When the user clicks on the “Reset password here” link on the email, I redirect them to the the “Reset password” page where they can create a new password.

Reset password page on Libertas

Code:

"use client";
import ErrorText from "@/components/errorComponents/ErrorText";
import TextInput from "@/components/formComponents/TextInput";
import LoadingButton from "@/components/pageComponents/LoadingButton";
import TitleText from "@/components/pageComponents/TitleText";
import { GlobalContext } from "@/services/globalContext";
import { colors } from "@/theme/colors";
import { Button, Stack } from "@mui/material";
import { useContext, useEffect, useState } from "react";

const ResetPassword = ({ params }) => {
const [user, setUser] = useState(null);
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");

const { loading, getSpecificUser, resetUserPassword, passwordResetError } =
useContext(GlobalContext);

useEffect(() => {
let mounted = true;

const fetchUser = async () => {
const data = await getSpecificUser(params.emailOrUsername);

if (data?.data?.success) {
setUser(data?.data?.user);
}
};

fetchUser();

return () => (mounted = false);
}, []);

const submitHandler = (e) => {
e.preventDefault();

resetUserPassword(user?._id, password, password2);
};

const isButtonDisabled = password.length > 0 && password2.length > 0;

return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div>
<TitleText title="Reset Password" />

<form onSubmit={submitHandler}>
<Stack spacing={2} style={{ textAlign: "center" }}>
{passwordResetError && <ErrorText message={passwordResetError} />}
<TextInput
type="password"
placeholder="New password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={true}
/>
<TextInput
type="password"
placeholder="Confirm password"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
required={true}
/>
<Button
variant="contained"
type="submit"
style={{
textTransform: "none",
backgroundColor: colors.button.background,
fontWeight: "600",
borderRadius: "0rem",
}}
sx={{
"&.Mui-disabled": {
color: "grey",
},
}}
disabled={!isButtonDisabled}
>
Change Password
{loading && (
<div style={{ marginLeft: "0.6rem" }}>
<LoadingButton />
</div>
)}
</Button>
</Stack>
</form>
</div>
</div>
);
};

export default ResetPassword;

The above code contains a form that runs a submitHandler function that further calls the resetUserPassword function that resets the user’s password.

7. Create controller to reset the password on the backend

I created a resetPassword controller to read both the password and confirmPassword values, compare both and update the user details with the new password.

const resetPassword = asyncHandler(async (req, res, next) => {
const { id, password, confirmPassword } = req.body;

if (!password || !confirmPassword) {
res.status(400);
return next(new Error("Both password fields are required"));
}

// Find the user
if (password !== confirmPassword) {
res.status(400);
return next(new Error("Passwords do not match"));
}

const hashedPassword = await bcrypt.hash(password, 10);

const updateUser = await UserModel.findByIdAndUpdate(
id,
{
password: hashedPassword,
},
{
new: true,
}
);

if (updateUser) {
res.status(201).json({
success: true,
message: "Password has been reset succesfully",
user: updateUser._id,
});
} else {
res.status(400);
return next(new Error("Something went wrong!"));
}
});

I have takes the above controller and used in this PUT route:

router.put("/reset-password", resetPassword);

Then I called the above route on the frontend.

Once the new password is validated, user will see the page below:

Password successfully reset message on the page

Code for the above page:

"use client";
import { Stack } from "@mui/material";
import Link from "next/link";
import { useEffect } from "react";

const ResetPasswordSuccess = () => {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div>
<Stack
alignItems="center"
spacing={2}
style={{ marginBottom: "2rem", textAlign: "center" }}
>
<h1>Password successfully reset!</h1>
<p>Let&apos;s go! Your password has been changed successfully!</p>
<Link
href="/login"
style={{
fontSize: "0.875rem",
color: "#000",
textDecoration: "underline",
}}
>
Login
</Link>
</Stack>
</div>
</div>
);
};

export default ResetPasswordSuccess;

Here, you have a two choices:

  • Automatically redirect the user to the “Login” page.
  • Leave it upto the user about the next steps.

8. Authenticate

Final step of the reset password flow is to login with the new password.

Login page on Libertas

That’s it! Was it too much? No worries, take it slow, step-by-step.

Conclusion

This post discussed how I created a password reset flow on Libertas. You can use the above process in your React/Next.js application too!


If you need any help, you can contact me on LinkedIn and Twitter. I usually try to reply fast.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top