Shahmir Faisal
Shahmir Faisal

Shahmir Faisal

Building Custom Authentication for Your Next.js Apps

Building Custom Authentication for Your Next.js Apps

A detailed guide on adding authentication using next.js, jwt, cookies, and mongodb.

Shahmir Faisal's photo
Shahmir Faisal
·Mar 26, 2022·

9 min read

Subscribe to my newsletter and never miss my upcoming articles

Play this article

Table of contents

Next.js is an open-source web development framework built on top of Node.js enabling you to build SEO-friendly React-based web apps by utilizing its features like server-side rendering and static-site generation.

Authentication

Handling authentication and authorization in your next.js app can be a bit tricky. There are so many options when it comes to adding auth like NextAuth, Auth0, Firebase, etc. And these are pretty easy to start with. But sometimes you might need to build your own authentication feature to suit our web app needs rather than using these pre-built solutions. So in this article, we'll be building our own authentication feature using jwt, cookies, and mongodb.

Let's Get Started

First, you need to have node.js installed on your machine. You can get it from their official website. Next, create an empty next.js project by running the following command:

npx create-next-app next-custom-auth --example "https://github.com/vercel/next-learn/tree/master/basics/learn-starter"

Now we need the following packages for our app:

Install the packages:

npm i axios cookies-next jsonwebtoken mongoose

Setting Up MongoDB

Create a '.env.local' file in the root of your project and add the environment variable for mongodb uri.

.env.local file

Now create a 'lib' folder in the root of your project, inside of that create a 'dbConnect.js'. Add the following code in it:

// lib/dbConnect.js

import mongoose from "mongoose";

async function dbConnect() {
  return await mongoose.connect(process.env.MONGODB_URI);
}

export default dbConnect;

Here, we're creating an asynchronous function that uses mongoose to connect to the MongoDB database. We will call this function whenever we need to perform database operations.

Building the UI

Now let's build the UI for the home, signup, and signin page.

Layout Component

Create a 'components' directory in the root of the project and then add a 'Layout.js' file.

// components/Layout.js

import Link from "next/link";

export default function Layout({ children }) {
  return (
    <>
      <nav>
        <Link href="/">
          <a>Home Page</a>
        </Link>

        <Link href="/signup">
          <a>SignUp</a>
        </Link>

        <Link href="/signin">
          <a>SignIn</a>
        </Link>
      </nav>

      <section>{children}</section>
    </>
  );
}

We will use this component for all of our pages. It adds a navigation bar and renders its children.

Home Page

Inside of your 'index.js' file in the pages directory add the following code:

// pages/index.js

import Layout from "../components/Layout";

export default function HomePage(props) {

  const signoutHandler = () => {
      // logic for signout
  };

  return (
    <Layout>
      <h1>Home Page</h1>
      <p>
        This is the home page and it is protected. Only authenticated users can
        access this page.
      </p>

      <p>
        <strong>Name</strong>: name
      </p>
      <p>
        <strong>Email</strong>: email
      </p>

      <button onClick={signoutHandler}>Sign out</button>
    </Layout>
  );
}

It will look like this:

home page

SignUp Page

Create a 'signup.js' file in the 'pages' directory and add the following code:

// pages/signup.js

import Layout from "../components/Layout";
import { useState } from "react";

export default function SignupPage() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const signupHandler = async (e) => {
    e.preventDefault();
    // signup logic
  };

  return (
    <Layout>
      <h1>SignUp</h1>

      <p>Only unauthenticated users can access this page.</p>

      <form onSubmit={signupHandler}>
        <input
          type="text"
          placeholder="Name"
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <input
          type="email"
          placeholder="Email"
          onChange={(e) => setEmail(e.target.value)}
          value={email}
        />
        <input
          type="password"
          placeholder="Password"
          onChange={(e) => setPassword(e.target.value)}
          value={password}
        />
        <button>SignUp</button>
      </form>
    </Layout>
  );
}

It will look like this:

signup paqe

SignIn Page

Create a 'signin.js' file in the pages directory and add the following code:

// pages/signin.js

import Layout from "../components/Layout";
import { useState } from "react";

export default function SigninPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const signinHandler = async (e) => {
    e.preventDefault();
    // Signin logic
  };

  return (
    <Layout>
      <h1>SignIn</h1>

      <p>Only unauthenticated users can access this page.</p>

      <form onSubmit={signinHandler}>
        <input
          type="email"
          placeholder="Email"
          onChange={(e) => setEmail(e.target.value)}
          value={email}
        />
        <input
          type="password"
          placeholder="Password"
          onChange={(e) => setPassword(e.target.value)}
          value={password}
        />
        <button>SignIn</button>
      </form>
    </Layout>
  );
}

It will look like this:

signin page I've added some CSS but I'm not going to show it here because styling isn't the focus of this article.

Creating the User Model

Create a 'models' directory at the root of the project. Inside, add a 'user.js' file and add the following code:

// models/user.js

import mongoose from "mongoose";

const UserSchema = new mongoose.Schema({
  name: String,
  email: String,
  password: String,
});

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

Here, we're creating a User schema using mongoose. It has the name, email, and password properties. Finally, we're creating a model and exporting it.

Building the API

Now we will work on the API. To begin, first, let's create a directory name 'api' in the 'pages' directory where we will add all of our endpoints.

Signup Endpoint

Create a 'signup.js' file in the 'api' directory and add the following code:

// pages/api/signup.js

import dbConnect from "../../lib/dbConnect";
import User from "../../models/user";
import jwt from "jsonwebtoken";
import { setCookies } from "cookies-next";

export default async function handler(req, res) {
  await dbConnect();

  const { name, email, password } = req.body;

  if (req.method === "POST") {
    const userExist = await User.findOne({ email });

    if (userExist)
      return res.status(422).json({ message: "Email already in use!" });

    const user = new User({ name, email, password });
    await user.save();

    const token = jwt.sign({ userId: user._id }, process.env.TOKEN_SECRET, {
      expiresIn: "1d",
    });

    setCookies("token", token, {
      req,
      res,
      maxAge: 60 * 60 * 24, // 1 day
      path: "/",
    });

    res.status(201).json(user);
  } else {
    res.status(424).json({ message: "Invalid method!" });
  }
}

Here, we created a handler function. Inside, we're using the dbConnect function to connect the database and destructed the properties from req.body. After that, we're only accepting POST requests. Then, we're validating the email and saving the user. We're creating a jsonwebtoken and storing it in a cookie using the setCookies method. The maxAge means it will expire after 24 hours. Make sure to add the TOKEN_SECRET in your .env.local file that you can use for creating jwts.

You can learn more about cookies-next from here.

SignIn Endpoint

Create a 'signin.js' file in the 'api' directory and add the following code:

// pages/api/signin.js

import dbConnect from "../../lib/dbConnect";
import User from "../../models/user";
import jwt from "jsonwebtoken";
import { setCookies } from "cookies-next";

export default async function handler(req, res) {
  await dbConnect();

  const { email, password } = req.body;

  if (req.method === "POST") {
    const user = await User.findOne({ email, password });

    if (!user)
      return res.status(422).json({ message: "Wrong email or password!" });

    const token = jwt.sign({ userId: user._id }, process.env.TOKEN_SECRET, {
      expiresIn: "1d",
    });

    setCookies("token", token, {
      req,
      res,
      maxAge: 60 * 60 * 24, // 1 day
      path: "/",
    });

    res.status(200).json(user);
  } else {
    res.status(424).json({ message: "Invalid method!" });
  }
}

It is very similar to the signup endpoint. Here, we're accepting the email and password of a user and validating it before letting the user to signin.

Connecting the API with Frontend

Now head over to the 'signup.js' file in the 'pages' directory and add the following code in the signupHandler function:

// pages/signup.js

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

    try {
      const res = await axios.post("/api/signup", {
        name,
        email,
        password,
      });

      router.push("/");
    } catch (error) {
      console.log(error);
    }
  };

Here we're using axios to send an HTTP request to the signup endpoint along with the name, email, and password of the user. Once the user signs up, we're redirecting to the home page using the router object provided by next.js.

You can import router and axios like so:

import axios from "axios";
import { useRouter } from "next/router";

// inside of the component
const router = useRouter();

Now head over to the 'signin.js' file in the 'pages' directory and add the following code in the signinHandler function:

// pages/signin.js

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

    try {
      const res = await axios.post("/api/signin", {
        email,
        password,
      });

      router.push("/");
    } catch (error) {
      console.log(error);
    }
  };

Here we're sending an HTTP request to the signin endpoint along with the email and password of the user. Once the user signs in, we're redirecting to the home page.

Now open the 'index.js' file in the 'pages' directory and add this code in signoutHandler function:

// pages/index.js

const signoutHandler = () => {
    removeCookies("token");
    router.push("/signin");
  };

It removes the 'token' cookie to signout the user.

Fetching User Data

Now we need to verify whether the user is signed in or not to restrict the home page from being accessed by an unauthenticated user.

Create a file 'getUser.js' in the 'lib' directory and add the following code:

// lib/getUser.js

import { getCookie } from "cookies-next";
import jwt from "jsonwebtoken";
import User from "../models/user";

export default async function getUser(req, res) {
  const token = getCookie("token", { req, res });

  try {
    const data = jwt.verify(token, process.env.TOKEN_SECRET);
    let user = await User.findById(data.userId);
    user = JSON.parse(JSON.stringify(user));
    return user;
  } catch (error) {
    return null;
  }
}

We will use the getUser function to verify the user. It takes in the req and res object and fetches the cookie. Then it verifies the token using the jwt.verify method. Then it fetches the user from the database using its id.
Remember, we stored the user's id in the token early in our article.
Then there's some serializing and parsing. What's that? Here, we're serializing and parsing the user document, it's because the _id of a document is of type ObjectId and we need to convert it into a string. You can avoid it and run the code to see what's going to happen. After that, we finally returned the user. If we don't have any user we will simply return null.

Now add this code in the 'index.js' file in your 'pages' directory:

// pages/index.js

export async function getServerSideProps({ req, res }) {
  await dbConnect();
  const user = await getUser(req, res);
  if (!user) {
    return {
      redirect: {
        permanent: false,
        destination: "/signin",
      },
      props: {},
    };
  }
  return {
    props: {
      user,
    },
  };
}

Here we're using server-side rendering to fetch the user data. If there's no user, we'll simply redirect to the 'signin' page otherwise we're good to go. You can the user data that we're returning as props in your component.

Now we need to make sure that only unauthenticated users can access the signin and signup pages. For both of the pages add the following code:

// pages/signup.js and pages/signin.js

export async function getServerSideProps({ req, res }) {
  await dbConnect();

  const user = await getUser(req, res);
  if (user) {
    return {
      redirect: {
        permanent: false,
        destination: "/",
      },
      props: {},
    };
  }
  return {
    props: {},
  };
}

If the user is available we will redirect to the home page.
That's it!

Conclusion

Adding authentication from scratch can be a bit spiny but you can go through this article to quickly understand how to do it. One thing which we missed was password hashing. You can do that by using the bcrypt package. The main goal was to build using jwt, cookies, and mongodb.

Here's the:
Live version of the app.
Source code.

Follow me on twitter.

 
Share this