The Password Reset Flow, From The User’s Side
Password fatigue is a real part of modern web life. No matter how carefully a user manages credentials, eventually many will find themselves staring at a login screen, unable to recall their password. The reset flow that follows is a make-or-break moment in the user experience; if it is clunky or insecure, you risk losing that user for good.
The standard reset flow follows a specific pattern.
- The user requests a reset link, typically by submitting their email address or username into a simple form.
- The application responds with a generic confirmation message. This should not verify the existence of the account, as revealing that information can aid phishing attempts. A neutral message like “An email has been sent to your inbox” encourages the user to double-check their input without confirming anything to a potential attacker.
- The email contains a link with a JWT and the user’s identifier, such as their email address.
- Clicking that link takes the user to a secure page where they can create a new password.
- The application verifies the token against the user’s account details before allowing the change to proceed.
If the token is invalid, an error should be displayed to the user. Otherwise, the operation completes, and the user can log in again.
Setting Up Dynamic Routes For Reset Tokens
Next.js makes this pattern straightforward through its file-based routing system. In a new project, the structure for this feature looks like this:
└── pages /
├── forgot-password/
│ └── [token]/
│ └── [email].js
├── _app.js
└── index.js
The key here is the naming convention. Files and folders wrapped in square brackets, like [token] and [email].js, create dynamic routes. These are real, accessible URLs — but the values they contain change depending on which user is accessing them. In this case, they enable a unique route for each reset attempt.
http://localhost:3000/forgot-password/token/email
Extracting URL Parameters With useRouter
The useRouter hook in Next.js exposes the current route’s data. Destructuring its query object is the cleanest way to access the dynamic segments from the URL.
import { useRouter } from 'next/router'
const { query } = useRouter()
Now you can pull the specific values you need. In our example, these are named token and email, mirroring the file names in the forgot-password directory. Naturally, if you rename the dynamic files, the query keys will follow suit.
const token = query.token
const email = query.email
Building The First Form: Requesting A Reset
The first interface the user sees is the forgot-password page. This is not just a technical hurdle but a place where you can build trust. The copy here should be reassuring, conveying empathy rather than judgment. A simple but kind message like, “Don’t worry, it happens to the best of us,” gets this across.
Beyond the text, this form has one core job: taking the user’s email and sending it to the backend to initiate the password reset process.
import { authEndpoints } from '../endpoints'
export const DefaultResetPassword = () => {
const handleForgot = async (e) => {
e.preventDefault()
try {
setLoading(true)
const response = await axios({
method: 'POST',
url: authEndpoints.recover,
data: {
email,
},
headers: {
'Content-Type': 'application/json',
},
})
setResestSuccess(response.data.msg)
setLoading(false)
setResetError('')
} catch (error) {
setLoading(false)
const { data } = error.response
setResetError(data.msg)
setResestSuccess(null)
}
}
return <div>{/* ...previous form component */}</div>
}
Inside the handler, Axios sends a POST request to the appropriate API endpoint, using the user’s email as payload. The submission state is managed through state variables—setLoading toggles a spinner or “submitting” state, while the response either triggers a success modal or, via the catch block, renders an error message returned by the server.
setResestSuccess(response.data.msg)
setLoading(false)
setResetError('')
catch (error) {
setLoading(false)
const { data } = error.response
setResetError(data.msg)
setResestSuccess(null)
}
To communicate the outcome, you can use custom modal components. One serves errors, the other successes; passing props and a bit of styling differentiates them. Adding type-checking with React’s PropTypes explicitly declares that these components expect a string for their message prop, catching potential bugs during development.
export const SuccessModal = ({ message }) => {
return (
<div className="auth-success-msg">
<p>{message}</p>
</div>
)
}
export const ErrModal = ({ message }) => {
return (
<div className="auth-err-msg">
<p>{message}</p>
</div>
)
}
propTypes.ErrModal = {
message: propTypes.string.isRequired,
}
propTypes.SuccessModal = {
message: propTypes.string.isRequired,
}
The Second Form: Creating A New Password
The reset page itself follows a similar pattern. It’s a form for the new password, but its crucial logic is in the URL. The page only renders properly if both the token and email parameters are present. If they are missing or invalid, showing a fallback message like “The page you’re looking for isn’t available” prevents the user from hitting an endless error loop.
{
email && token ? (
<div className="auth-wrapper">
<FormComponentt />
</div>
) : (
<p>The page you’re trying to get to isn’t available</p>
)
}
The handler function for this form collects the new password along with the token and email from the URL. It sends all three in a POST request to the backend, which validates the token’s authenticity and authorization for that specific account.
import { authEndpoints } from '../endpoints'
const resetPassword = async (e) => {
e.preventDefault()
try {
setLoading(true)
const response = await axios({
method: 'POST',
url: authEndpoints.resetPassword,
data: {
token,
email,
password: newPassword,
},
headers: {
'Content-Type': 'application/json',
},
})
setResetPasswordSuccess(response.data.msg)
setLoading(false)
setTimeout(() => {
router.push('/')
}, 4000)
setResetPasswordError('')
} catch (error) {
setLoading(false)
setResetPasswordError(error.response.data.msg)
setResetPasswordSuccess(null)
}
}
After a successful password change, the final UX touch is redirecting the user back to the login page. JavaScript’s setTimeout, combined with Next.js’ useRouter hook, can handle this elegantly, giving the success message a moment to register before automatically navigating the user away.
setTimeout(() => {
router.push('/')
}, 4000)
This automatic redirect relieves the user of hunting for that last link, and quickly gets them back to the only action they care about now: logging in with their new password.
Beyond the Form: UX and Security in Password Resets
Building the password reset flow frontend is only half the work. For the feature to actually succeed, it needs to be secure and respectful of the user's context. If users cannot complete the reset quickly and without confusion, they will either abandon the process or contact support, which defeats the purpose of the feature.
Proper error handling is critical. The system must navigate users to the right screen based on the status of their reset token. If a token is invalid, expired, or the email link is used more than once, the user should not be stuck on a broken form. Instead, they should be redirected to a page that prompts them to request a new link, keeping the flow moving forward.
Balance security measures with user convenience. Limiting failed password confirmation attempts is a standard security practice to prevent brute-force attacks. However, applying such limits too aggressively can frustrate legitimate users.
Finally, never assume the user journey ends at the form submission. A successful password change should direct the user to the login page so they can immediately authenticate with their new credentials. A completed reset implies the user is now ready to continue their original task, and the flow should usher them along that path without delay.



