Next.js and MongoDB full-fledged app Part 3: Email Verification, Password Reset/Change
Tags: nextjs, mongodb, tutorial, reactnative
Published: 2021-09-24 18:09:30

(this post is actually rewritten from this [old post](https://dev.to/hoangvvo/how-i-build-a-full-fledged-app-with-next-js-and-mongodb-part-3-email-verification-password-reset-change-l3i) to include new changes to the project)

This is a follow-up to [Part 2](/blog/next-js-and-mongodb-app-2/ "Next.js and MongoDB full-fledged app Part 1: User profile and Profile Picture"). Make sure you read it before this post. 

This time I am adding the following features: Email Verification, Password Reset, and Password Change. 

Again, Below are the Github repository and a demo for this project to follow along.

[Github repo](https://github.com/hoangvvo/nextjs-mongodb-app "nextjs mongodb app")

[Demo](https://nextjs-mongodb.vercel.app/)

## About `nextjs-mongodb-app` project

> nextjs-mongodb-app is a Full-fledged serverless app made with Next.JS and MongoDB

Different from many other Next.js tutorials, this:

- Does not use the enormously big Express.js, supports `serverless`
- Minimal, no fancy stuff like Redux or GraphQL for simplicity in learning
- Using Next.js latest features like API Routes or getServerSideProps

For more information, visit the [Github repo](https://github.com/hoangvvo/nextjs-mongodb-app "nextjs mongodb app").

## What we are making

We are working on several features, which all involve email transactions.

- **Email Verification** allows you to verify the emails users used to sign up by sending them a verification link.

- **Password Reset** allows the users to reset their passwords from a reset link sent to their emails.

- **Password Change** allows the users to change their passwords simply by inputting their old and new passwords.

## Building Password Change

This is the easiest feature to implement. All we have to do is match the user's old password against the database and save their new one.

### Password Change Section

I added the section right below the Profile Settings page.

![Password Change Page](https://hoangvvo.com/blog/next-js-and-mongodb-app-3/password-change-page.png)

```jsx
import { useCurrentUser } from "@/lib/user";
import { useRouter } from "next/router";
import { useEffect, useCallback } from "react";
import { fetcher } from "@/lib/fetch";

const AboutYou = ({ user, mutate }) => {
  /* ... */
};

const Auth = () => {
  const oldPasswordRef = useRef();
  const newPasswordRef = useRef();

  const onSubmit = useCallback(async (e) => {
    e.preventDefault();
    try {
      await fetcher("/api/user/password", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          oldPassword: oldPasswordRef.current.value,
          newPassword: newPasswordRef.current.value,
        }),
      });
    } catch (e) {
      console.error(e.message);
    } finally {
      oldPasswordRef.current.value = "";
      newPasswordRef.current.value = "";
    }
  }, []);

  return (
    <section>
      <h4>Password</h4>
      <form onSubmit={onSubmit}>
        <input
          type="password"
          autoComplete="current-password"
          ref={oldPasswordRef}
          placeholder="Old Password"
        />
        <input
          type="password"
          autoComplete="new-password"
          ref={newPasswordRef}
          placeholder="New Password"
        />
        <button type="submit">Save</button>
      </form>
    </section>
  );
};

const SettingsPage = () => {
  const { data, error, mutate } = useCurrentUser();
  const router = useRouter();
  useEffect(() => {
    if (!data && !error) return; // useCurrentUser might still be loading
    if (!data.user) {
      router.replace("/login");
    }
  }, [router, data, error]);
  if (!data?.user) return null;
  return (
    <>
      <AboutYou user={data.user} mutate={mutate} />
      <Auth />
    </>
  );
};

export default SettingsPage;
```

When the user submits, we make a PUT call to `/api/user/password` with the old and new password. After the request, we clear the new and old password fields.

We now go ahead and create our API at `/api/user/password`.

### Password Change API

Create `/pages/api/user/password/index.js`.

```javascript
import { auths, database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";

const handler = nc();
handler.use(database, ...auths);

handler.put(
  validateBody({
    type: "object",
    properties: {
      oldPassword: { type: "string", minLength: 8 },
      newPassword: { type: "string", minLength: 8 },
    },
    required: ["oldPassword", "newPassword"],
    additionalProperties: false,
  }),
  async (req, res) => {
    if (!req.user) {
      res.json(401).end();
      return;
    }

    const { oldPassword, newPassword } = req.body;

    // We could not req.user because that object does not have the `password` field
    const currentUser = await db
      .collection("users")
      .findOne({ _id: req.user._id });

    const matched = await bcrypt.compare(oldPassword, currentUser.password);

    if (!matched) {
      res.status(401).json({
        error: { message: "The old password you entered is incorrect." },
      });
      return;
    }

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

    await req.db
      .collection("users")
      .updateOne({ _id: currentUser._id }, { $set: { password } });

    res.status(204).end();
  }
);

export default handler;
```

We first validate the body using our [validateBody middleware](/blog/next-js-and-mongodb-app-1#request-body-validation-middleware). Then, we check if the user is logged in by checking `req.user`. If not, it will send a [401](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) response.

Then, we fetch the user object, containing hashed password field. (In the previous version of this project, I directly compare using `req.user.password`. However, the new version uses `projection` to omit that field when authenticating users for security reasons.)

We then go ahead and retrieve `oldPassword` and `newPassword` from the request body. The `oldPassword` is compared against the _hashed_ current password (`bcrypt.compare(oldPassword, currentUser.password)`). If it does not match, we reject the request. If it does we hash the new password (`bcrypt.hash(newPassword, 10)`) and save it in our database.

```javascript
await req.db
  .collection("users")
  .updateOne({ _id: req.user._id }, { $set: { password } });
```

And the feature is ready to roll~

![Password Change](https://hoangvvo.com/blog/next-js-and-mongodb-app-3/password-change.gif)

(Note: The GIF is from an old, barebone version :))

## Password Reset

Now that the user can change their current password to a new one. Yet that is only when they know their current password. Let's implement the password reset feature.

### Forget Password API

We will have two routes for this API.

- `POST /pages/api/user/password/reset`: Handle requests to create a password reset token and send email
- `PUT /pages/api/user/password/reset`: Reset password using a token.

#### Password reset request

Create `/pages/api/user/password/reset/index.js`:

```javascript
import { sendMail } from "@/api-lib/mail";
import { database, validateBody } from "@/api-lib/middlewares";
import nc from "next-connect";
import normalizeEmail from "validator/lib/normalizeEmail";
import { nanoid } from "nanoid";

const handler = nc();

handler.use(database);

handler.post(
  validateBody({
    type: "object",
    properties: {
      email: { type: "string", minLength: 1 },
    },
    required: ["email"],
    additionalProperties: false,
  }),
  async (req, res) => {
    const email = normalizeEmail(req.body.email);

    const user = await req.db.collection("users").findOne({ email });

    if (!user) {
      res.status(400).json({
        error: { message: "We couldn’t find that email. Please try again." },
      });
      return;
    }

    const securedTokenId = nanoid(32); // create a secure reset password token

    await db.collection("tokens").insertOne({
      _id: securedTokenId,
      creatorId: user._id,
      type: "passwordReset",
      expireAt: new Date(Date.now() + 20 * 60 * 1000), // let's make it expire after 20 min
    });

    await sendMail({
      to: user.email,
      from: "no-reply@nextjs-mongodb.vercel.app",
      subject: "[nextjs-mongodb-app] Reset your password.",
      html: `
      <div>
        <p>Hello, ${user.name}</p>
        <p>Please follow <a href="${process.env.WEB_URI}/forget-password/${securedTokenId}">this link</a> to reset your password.</p>
      </div>
      `,
    });

    res.status(204).end();
  }
);

export default handler;
```

We only need our database middleware in this API because we don't need any authentication info.

We first verify if there is a user with such email in the database `req.db.collection('users').findOne({ email: req.body.email })`.

If the user exists, we create a secure `passwordReset` token. We insert a document to our `tokens` collection with the created token along with the intended user's \_id in `creatorId`. The secure token is set as the document `_id` (since `_id` is indexed by MongoDB, it allows faster lookup)

To set the token to expire after the `expireAt` property for security reasons, we can create an index on that collection like below (usually called when the server starts):

```javascript
db.collection("tokens").createIndex("expireAt", { expireAfterSeconds: 0 });
```

We then send an email to the user with a password reset link (`website_url/forget-password/{token}`).

Note: You have to implement the [sendEmail](https://github.com/hoangvvo/nextjs-mongodb-app/blob/f2ecbb1bd1dc0a0ca2b4f0f09c224ac2ea9a8c42/api-lib/mail.js#L12) function.

### Forget Password page

![Password Reset Page](https://hoangvvo.com/blog/next-js-and-mongodb-app-3/password-reset-page.png)

We now create a forget password page at `/pages/forget-password/index.jsx`

```jsx
import { fetcher } from "@/lib/fetch";
import { useCallback, useRef } from "react";

const ForgetPasswordPage = () => {
  const emailRef = useRef();

  const onSubmit = useCallback(async (e) => {
    e.preventDefault();
    try {
      await fetcher("/api/user/password/reset", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          email: emailRef.current.value,
        }),
      });
    } catch (e) {
      console.error(e.message);
    }
  }, []);

  return (
    <>
      <Head>
        <title>Forget password</title>
      </Head>
      <h1>Forget password</h1>
      <p>
        Enter the email address associated with your account, and we&apos;ll
        send you a link to reset your password.
      </p>
      <form onSubmit={onSubmit}>
        <input
          ref={emailRef}
          type="email"
          autoComplete="email"
          placeholder="Email"
        />
        <button type="submit">Submit</button>
      </form>
    </>
  );
};

export default ForgetPasswordPage;
```

We simply ask the user for their email, which we then send to our just-created API above at '/api/user/password/reset'.

### Reset Password API

We need an API to resolve the reset token.

Let's add a _PUT_ request handler to `/pages/api/user/password/reset.js`:

```javascript
import nc from "next-connect";
import bcrypt from "bcryptjs";
import database from "@/api-lib/middlewares";

const handler = nc();

handler.use(database);

handler.post(/* ... */);

handler.put(
  validateBody({
    type: "object",
    properties: {
      password: { type: "string", minLength: 8 },
      token: { type: "string", minLength: 0 },
    },
    required: ["password", "token"],
    additionalProperties: false,
  }),
  async (req, res) => {
    const deletedToken = await db
      .collection("tokens")
      .findOneAndDelete({ _id: id, type });

    if (!deletedToken) {
      res.status(403).end();
      return;
    }

    const password = await bcrypt.hash(newPassword, 10);
    await db
      .collection("users")
      .updateOne(
        { _id: deletedToken.creatorId },
        { $set: { password } }
      );

    res.status(204).end();
  }
);

export default handler;
```

The handler will try to find and delete the token we inserted earlier. If `deleteToken` is not null, we also know that the token is deleted from the database (we don't want the same token to be used twice).

If the token is null, we simply reject the request.

We then hash the password using `bcrypt` and update the user whose `_id` can be found at `token.creatorId`.

### Reset Password page

This page represents the link that is sent to the user's email (`website_url/forget-password/{token}`). Create `/pages/forget-password/[token].jsx`:

```jsx
import { database } from "@/api-lib/middlewares";
import nc from "next-connect";
import Head from "next/head";

const ResetPasswordTokenPage = ({ valid, token }) => {
  const passwordRef = useRef();

  const onSubmit = useCallback(
    async (event) => {
      event.preventDefault();
      setStatus("loading");
      try {
        await fetcher("/api/user/password/reset", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            token,
            password: passwordRef.current.value,
          }),
        });
      } catch (e) {
        console.error(e.message);
      }
    },
    [token]
  );

  if (!valid)
    return (
      <>
        <h1>Invalid Link</h1>
        <p>
          It looks like you may have clicked on an invalid link. Please close
          this window and try again.
        </p>
      </>
    );

  return (
    <>
      <Head>
        <title>Forget password</title>
      </Head>
      <h1>Forget password</h1>
      <p>Enter a new password for your account</p>
      <form onSubmit={onSubmit}>
        <input
          ref={passwordRef}
          type="password"
          autoComplete="new-password"
          placeholder="New Password"
        />
        <button type="submit">Reset password</button>
      </form>
    </>
  );
};

export async function getServerSideProps(context) {
  await nc().use(database).run(context.req, context.res);

  const tokenDoc = await db.collection("tokens").findOne({
    _id: context.params.token,
    type: "passwordReset",
  });

  return { props: { token: context.params.token, valid: !!tokenDoc } };
}

export default ResetPasswordTokenPage;
```

For this page, we use [getServerSideProps](https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering) to check the token validity (This function is run server-side only). The `token` can be found in `context.params` since our page is a dynamic route (with dynamic `token` parameter: `/pages/forget-password/[token].jsx`).

Our [`database` middleware](/blog/next-js-and-mongodb-app-1#database-middleware) is used to load the database into `req.db`. We use it to check if the token can be found in the database by querying its `_id` (we set the secure token as the document `_id` earlier).

The page component will know if the token is valid based on the `valid` prop, which is `true` if the token document can be found and `false` otherwise.

As we can see, if `valid` is `false`, we show a "Invalid Link" message.

![Invalid link message](https://hoangvvo.com/blog/next-js-and-mongodb-app-3/reset-password-invalid-link.png)

Otherwise, we render an input for a new password.

![Reset password valid page](https://hoangvvo.com/blog/next-js-and-mongodb-app-3/reset-password-valid-page.png)

After the user submit the new password, we simply make a request to the just created API along with the token received from props.

```javascript
await fetcher("/api/user/password/reset", {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    token,
    password: passwordRef.current.value,
  }),
});
```

## Building Email Verification

Let's finish up with email verification. This feature is similar to password reset as we similarly send a link with the token to the user.

### Email Verification API

Get started by creating `/pages/api/user/email/verify.js`. This will handle users' requests to receive a confirmation email.

```jsx
import { sendMail } from "@/api-lib/mail";
import { auths, database } from "@/api-lib/middlewares";
import nc from "next-connect";

const handler = nc();

handler.post(async (req, res) => {
  if (!req.user) {
    res.json(401).end();
    return;
  }

  const securedTokenId = nanoid(32);

  const token = await {
    _id: securedTokenId,
    creatorId: req.user._id,
    type: "emailVerify",
    expireAt: new Date(Date.now() + 1000 * 60 * 60 * 24), // expires in 24h
  };

  await sendMail({
    to: req.user.email,
    from: "no-reply@nextjs-mongodb.vercel.app",
    subject: `Verification Email for ${process.env.WEB_URI}`,
    html: `
      <div>
        <p>Hello, ${req.user.name}</p>
        <p>Please follow <a href="${process.env.WEB_URI}/verify-email/${token._id}">this link</a> to confirm your email.</p>
      </div>
      `,
  });

  res.status(204).end();
});

export default handler;
```

In this handler, we are checking if the user is logged in and create a token that associates the user ID `req.user._id`. This token will expire in 24 hours.

The token creation and email sending are similar to those of Reset Password.

### Email Verification page

Similar to the password reset page, we create a dynamic route page at `/pages/verify-email/[token].jsx`.

```jsx
import { database } from "@/api-lib/middlewares";
import nc from "next-connect";
import Head from "next/head";

export default function EmailVerifyPage({ valid }) {
  return (
    <>
      <Head>
        <title>Email verification</title>
      </Head>
      <p>
        {valid
          ? "Thank you for verifying your email address. You may close this page."
          : "It looks like you may have clicked on an invalid link. Please close this window and try again."}
      </p>
    </>
  );
}

export async function getServerSideProps(context) {
  const handler = nc(ncOpts);
  handler.use(database);
  await handler.run(context.req, context.res);

  const { token } = context.params;

  const deletedToken = await db
    .collection("tokens")
    .findOneAndDelete({ _id: token, type: "emailVerify" });

  if (!deletedToken) return { props: { valid: false } };

  await db.collection("users").updateOne(
    { _id: deletedToken.creatorId },
    {
      emailVerified: true,
    }
  );

  return { props: { valid: true } };
}
```

For this page, we do the email verification process inside `getServerSideProps`. Similar to the reset password feature, we find and delete the token from the database. If the token is found (and thus deleted), we update the user whose `_id` found in the token `creatorId` to have `emailVerify = true`.

The returned prop `valid` inform the UI to show the correct message.

![Verify email page](https://hoangvvo.com/blog/next-js-and-mongodb-app-3/email-verification-page.png)

### Present the email verification status

The returned value of our `/api/user` actually contains a property call `emailVerified`, as seen above.

Therefore, we can use our [`useCurrentUser`](https://github.com/hoangvvo/nextjs-mongodb-app/blob/v2/lib/user/hooks.js#L4) SWR hook to show a message asking the user to verify his or her email.

```jsx
export default () => {
  const {
    data: { user },
  } = useCurrentUser();
  if (!user.emailVerified)
    return (
      <p>
        <strong>Note:</strong> <span>Your email</span> (<span className="link">
          {user.email}
        </span>) is unverified.
      </p>
    );
  return null;
};
```

![Email verify note](https://hoangvvo.com/blog/next-js-and-mongodb-app-3/verify-email-note.png)

## Conclusion

Tadah! We have managed to have the password change, password reset and email verification features in our app.

Check out the repository [nextjs mongodb app](https://github.com/hoangvvo/nextjs-mongodb-app).

I have spent hours writing these articles and even dozen more working on this project. If you find it helpful, I would love to see it getting shared and starred. Until then, good luck on your Next.js project!