Build an email and password authentication system with NextAuth.js and MongoDB

Wed Apr 05 2023

In this tutorial, we'll learn how to implement an email and password authentication system in Next.js app using NextAuth.js and MongoDB. Specifically, we'll focus on using the Credentials Provider from NextAuth.js to handle authentication with email and password credentials. We'll go through the process of setting up a MongoDB connection with Mongoose, defining a user schema, and configuring NextAuth.js with the Credentials Provider to handle login and registration. By the end of this tutorial, you'll have a solid understanding of how to use NextAuth.js and MongoDB to build a secure authentication system for your Next.js app that supports email and password authentication.

Let's create a next app. Make sure you have nodejs installed. Navigate into desired directory, open terminal and type

npx create-next-app nextjs-auth-demo

This will create a nextjs boilerplate app. Navigate into the project director and now we will install a few packages. In terminal type

npm install mongoose bcryptjs next-auth

  • mongoose - An Object Data Modeling (ODM) library for MongoDB and Node.js. It provides a straightforward, schema-based solution to model application data and includes built-in type casting, validation, query building, and more. In short, it makes working with MongoDB in Node.js much easier and more organized.

  • bcryptjs - A library for hashing and salting passwords. Hashing is the process of converting a password into an irreversible format, making it difficult for attackers to reverse engineer the original password. Salting is the process of adding a random string to a password before hashing, further increasing the security of the hashed password. Bcryptjs provides an easy-to-use interface for hashing and salting passwords in Node.js.

  • next-auth - A library for handling authentication in Next.js applications. It supports a wide range of authentication providers, including email and password, social media, and more.

Open the project in code editor. We will first create a mongodb connection. At the root level create a folder utils and inside it create a file db.js. You may call it anything you like. Inside db.js paste the following code

import mongoose from "mongoose";

const initDB = () => {
  // Dont connect to DB if already connected
  if (mongoose.connections[0].readyState) {
    console.log("already connected");
    return;
  }
  // Connect to DB
  mongoose.connect(process.env.MONGO_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
  });
  mongoose.connection.on("connected", () => {
    console.log("connected to mongo");
  });
  mongoose.connection.on("error", (err) => {
    console.log("error connecting", err);
  });
};

export default initDB;

At the top we imported mongoose library. We then created a function initDB and within this function we first checked if there is already a connection to the MongoDB database. If there is, it logs a message to the console saying that the connection is already established and returns from the function.

After that we connected to the MongoDB database using the MONGO_URI environment variable, which is a string that specifies the MongoDB connection URL. The useNewUrlParser and useUnifiedTopology options are used to avoid deprecation warnings.

We then setup two callback functions. connected will be called when the MongoDB database is connected. It logs a message to the console saying that the connection is established. error will be called if there is an error connecting to the MongoDB database. It logs a message to the console saying that there was an error and includes the error object.

Finally we exported initDB function so that it can be used in other files. At the root level create a .env.local file and add MONGO_URI and paste your database url there

Next we will create User model. At root level, create a folder models and inside it create a file User.js. Within User.js paste the following code

import mongoose from "mongoose";
import bcrypt from "bcryptjs";

const userSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: true,
    },
    email: {
      type: String,
      required: true,
      unique: true,
    },
    password: {
      type: String,
      required: true,
    },
  },
  { timestamps: true }
);

userSchema.pre("save", async function (next) {
  // Hash password before saving user
  if (this.isModified("password")) {
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
  }
  next();
});

userSchema.methods.matchPassword = async function (password) {
  // Compare provided password with hashed password
  return await bcrypt.compare(password, this.password);
};

export default mongoose.models.User || mongoose.model("User", userSchema);

At the top, we imported mongoose and bcryptjs. We then created new mongoose schema having name, email and password fields. We also added timestamps to get createdAt and updatedAt date time fields.

We then defined a pre-save hook (middleware function) for the User schema in Mongoose. The purpose of this middleware function is to hash the user's password before saving it to the database. The function is triggered before the save() method is called on a User document, and it checks if the password field has been modified. If it has, it generates a salt (a random string used to increase the security of the hash) using bcrypt.genSalt(), with a cost factor of 10. It then hashes the password using bcrypt.hash() and the generated salt, and sets the password field to the resulting hash. Finally, it calls the next() function to continue the middleware chain and save the user to the database. By using this pre-save hook, the user's password is securely hashed and stored in the database, making it more difficult for an attacker to obtain the original password even if they manage to access the database.

Next we defined a method on the User schema in Mongoose called matchPassword(). The purpose of this method is to compare a plain text password with the hashed password stored in the database for a particular user. The method returns the result of the comparison, which is a boolean value indicating whether the two passwords match or not. If they match, the function returns true, indicating that the password provided by the user is correct. If they don't match, the function returns false, indicating that the password provided by the user is incorrect.

We will use pre-save middleware function when for new user registration and matchPassword method when user try to login.

Finally, the exported value is either the existing User model if it exists, or a new User model if it doesn't exist yet. This allows other parts of the codebase to import the User model and use it without worrying about whether it has already been defined or not.

Now we will create register endpoint. In pages/api create a file register.js and paste the following code

import User from "@/models/User";
import initDB from "@/utils/db";

initDB();

export default async function handler(req, res) {
  const { name, email, password } = req.body;

  try {
    let user = await User.findOne({ email });
    if (user) {
      return res.status(400).json({ error: "User already exists" });
    }
    user = new User({ name, email, password });
    await user.save();

    res.json({
      message: "Account created",
    });
  } catch (error) {
    console.log(error);
  }
}

At the top we imported User model and initDB. Next we called initDB function. We created an asynchronous function handler which takes req and res as arguements and also export it as a default export from this file. Inside handler function we extracted name, email and password from req.body.

Next we try to find a user in the database with the same email as the one provided in the request body. If a user is found, the function returns a HTTP 400 error response with a JSON object containing an error message.

If there is no user with the provided email we then create a new User document in the database with the provided name, email, and password properties, and save it to the database using the save() method. Remember, before save method pre-save middleware function from User model will run which will hash the password.

To import file as @/models/User you have to create a file at root level jsconfig.json with the following code

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

This is a configuration file for compiler that sets the mapping of module names to file paths. Specifically, it sets the path for @/* to point to the current directory ./. This allows you to use relative paths to import modules instead of having to use the full path from the root directory of the application.

Go to pages directory. You will see api directory there. Within api create a file [...nextauth].js and paste the following code

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import User from "@/models/User";
import initDB from "@/utils/db";

initDB();

export default NextAuth({
  providers: [
    CredentialsProvider({
      name: "Sign in with Email",
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (credentials == null) return null;
        // login
        const { email, password } = credentials;
        const user = await User.findOne({ email });
        if (!user) {
          throw new Error("Invalid credentials");
        }
        const isMatch = await user.matchPassword(password);
        if (!isMatch) {
          throw new Error("Invalid credentials");
        }
        return user;
      },
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user._id.toString(); // Convert ObjectID to string
      }
      return token;
    },
    async session({ session, token }) {
      if (token?.id) {
        session.user = { id: token.id, name: token.name, email: token.email };
      }
      return session;
    },
  },
});

This is a default export of a NextAuth configuration object which contains

  • providers - An array of authentication providers that the app will support. In this case, there is only one provider, CredentialsProvider, which is used for email and password authentication. It defines the name of the provider ("Sign in with Email") and the credentials required (email and password).

  • authorize - This is an async function that is called when a user tries to sign in. It takes the user's credentials and returns a user object if the credentials are valid, or throws an error if they are not. In this case, it uses the User model to find a user with the given email, checks if the password matches, and returns the user object if it does.

  • secret - A secret string used to encrypt and sign cookies and tokens.

  • callbacks - An object that contains two async functions (jwt and session) that are called after a user successfully logs in. The jwt function takes the user's token and adds an id property to it with the user's _id value as a string. The session function takes the user's session and adds a user property to it with the id, name, and email properties of the user's token, if the token.id property exists.

To use next-auth we have to wrap our entire app with the SessionProvider provided by next-auth. In pages directory, go to _app.js and replace the existing code with the following code

import "../styles/globals.css";
import Navbar from "@/components/Navbar";
import Script from "next/script";
import { SessionProvider } from "next-auth/react";

function MyApp({ Component, pageProps: { session, ...pageProps } }) {
  return (
    <SessionProvider session={session}>
      <Navbar />
      <Component {...pageProps} />
      <Script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" />
    </SessionProvider>
  );
}

export default MyApp;

Here we have wrapped our entire app with SessionProvider. Next we added Navbar file here which we will create in the next step and at the end there is a bootstrap js CDN link in Script tag. Before we create Navbar file within pages directory create a file _document.js and paste the following code

import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html>
      <Head>
        <link
          rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

We are using this file to add bootstrap css CDN link. Create a folder components at the root level and inside it create a file Navbar.js and paste the following code

import React from "react";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";

const Navbar = () => {
  const { data } = useSession();

  return (
    <nav className="navbar navbar-expand-lg bg-body-tertiary">
      <div className="container-fluid">
        <Link className="navbar-brand" href="/">
          Next Auth
        </Link>
        <button
          className="navbar-toggler"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#navbarSupportedContent"
          aria-controls="navbarSupportedContent"
          aria-expanded="false"
          aria-label="Toggle navigation"
        >
          <span className="navbar-toggler-icon"></span>
        </button>
        <div className="collapse navbar-collapse" id="navbarSupportedContent">
          <ul className="navbar-nav ms-auto mb-2 mb-lg-0">
            {data?.user ? (
              <li className="nav-item">
                <button className="btn btn-danger btn-sm" onClick={signOut}>
                  Logout
                </button>
              </li>
            ) : (
              <>
                <li className="nav-item">
                  <Link className="nav-link" href="/register">
                    Register
                  </Link>
                </li>
                <li className="nav-item">
                  <Link className="nav-link" href="/login">
                    Login
                  </Link>
                </li>
              </>
            )}
          </ul>
        </div>
      </div>
    </nav>
  );
};

export default Navbar;

We are using useSession hook and signOut function from next-auth library. If user is authenticated then data object (which will come from useSession hook) will have user. In that case we are rendering Logout button otherwise Register and Login links.

In pages directory create a file register.js and paste the following code

import React, { useState } from "react";
import { useRouter } from "next/router";

const Register = () => {
  const router = useRouter();

  const [values, setValues] = useState({
    name: "",
    email: "",
    password: "",
  });
  const [error, setError] = useState(null);

  const { name, email, password } = values;

  const handleChange = (e) =>
    setValues({ ...values, [e.target.name]: e.target.value });

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!name || !email || !password) {
      setError("All fields are required");
      return;
    }
    const res = await fetch("/api/register", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(values),
    });
    const result = await res.json();
    if (res.ok) {
      setValues({ name: "", email: "", password: "" });
      router.replace("/login");
    } else {
      setError(result.error);
    }
  };

  return (
    <form style={{ maxWidth: "576px", margin: "auto" }} onSubmit={handleSubmit}>
      <h3 className="text-center my-5">Create an account</h3>
      <div className="mb-3">
        <label htmlFor="name">Name</label>
        <input
          type="text"
          className="form-control"
          name="name"
          value={name}
          onChange={handleChange}
        />
      </div>
      <div className="mb-3">
        <label htmlFor="email">Email</label>
        <input
          type="email"
          className="form-control"
          name="email"
          value={email}
          onChange={handleChange}
        />
      </div>
      <div className="mb-3">
        <label htmlFor="password">Password</label>
        <input
          type="password"
          className="form-control"
          name="password"
          value={password}
          onChange={handleChange}
        />
      </div>
      {error && <p className="text-danger text-center">{error}</p>}
      <div className="mb-3 text-center">
        <button className="btn btn-secondary btn-sm">Register</button>
      </div>
    </form>
  );
};

export default Register;

In handleSubmit function we are making a HTTP POST request to our register endpoint. If request is successful then user will be redirected to the login page. Next in pages directory create a file login.js and paste the following code

import React, { useState } from "react";
import { useRouter } from "next/router";
import { signIn } from "next-auth/react";

const Login = () => {
  const router = useRouter();

  const [values, setValues] = useState({
    email: "",
    password: "",
  });
  const [error, setError] = useState(null);

  const { email, password } = values;

  const handleChange = (e) =>
    setValues({ ...values, [e.target.name]: e.target.value });

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!email || !password) {
      setError("All fields are required");
      return;
    }
    const res = await signIn("credentials", {
      redirect: false,
      email,
      password,
    });
    if (res.error) {
      setError(res.error);
      return;
    }

    setValues({ email: "", password: "" });
    router.replace("/");
  };

  return (
    <form style={{ maxWidth: "576px", margin: "auto" }} onSubmit={handleSubmit}>
      <h3 className="text-center my-5">Log into your account</h3>
      <div className="mb-3">
        <label htmlFor="email">Email</label>
        <input
          type="email"
          className="form-control"
          name="email"
          value={email}
          onChange={handleChange}
        />
      </div>
      <div className="mb-3">
        <label htmlFor="password">Password</label>
        <input
          type="password"
          className="form-control"
          name="password"
          value={password}
          onChange={handleChange}
        />
      </div>
      {error && <p className="text-danger text-center">{error}</p>}
      <div className="mb-3 text-center">
        <button className="btn btn-secondary btn-sm">Login</button>
      </div>
    </form>
  );
};

export default Login;

In handleSubmit we called signIn function provided by next-auth. The first argument is provider name which in this case is credentials and in second argument we have passed an object with redirect, email and password. We used redirect false to prevent page reload because we are using next router to navigate to home page upon successful authentication.

Save all the files and and in terminal type

npm run dev

We have explored the process of building an authentication system with credentials using NextAuth.js and MongoDB. We covered the installation of necessary dependencies, such as next-auth, mongoose, and bcryptjs, for handling authentication and storing user data securely in MongoDB. We also discussed the implementation of user registration and login functionality, including password hashing and matching, with next-auth and mongoose.

Share

Privacy Policy
icon
icon
icon
icon

Developed By Farhan Farooq