How to Add Complete Authentication to Your NextJS App

ยท

33 min read

Integrating authentication in your application is vital as it helps to establish trust, security, and user retention. It also helps in maintaining personalized user experience and permissions.

In today's blog, we will learn about how we can integrate advanced authentication into our NextJS project. We will be diving into a lot of things like Zod, next-auth, mail verification, and a lot of other things. I have chosen Next over React since it's becoming more and more popular and it's quite the go-to framework for web dev now.

๐Ÿ’ก
Disclaimer: This project is not for complete beginners in NextJS. I will assume you know the basics of NextJS, TailwindCSS and basic CRUD in MERN stack. The complete final code for the project is available here. If you want to see a preview of the live app, here's the link.

Getting Started

Create your NextJS project using this command:

npx create-next-app you_app_name

Next, you will see various prompts. Here are my choices:

npx create-next-app .
โˆš Would you like to use TypeScript? ...  Yes
โˆš Would you like to use ESLint? ...  Yes
โˆš Would you like to use Tailwind CSS? ...  Yes
โˆš Would you like to use `src/` directory? ... No 
โˆš Would you like to use App Router? (recommended) ...  Yes
โˆš Would you like to customize the default import alias (@/*)? ... No

You are free to use your own choices since the code will be more or less the same.

Creating User Model

First, we will start by creating the User model. For that, we will be using mongoose (a popular utility to talk with MongoDB database and data modeling):

npm i mongoose
๐Ÿ’ก
In NextJS, folder structure is very important and I will be giving the location of the file whose code I am currently showing. If some file or folder is not present by default, make sure to create it.

parent folder\model\User.ts

import mongoose, { Schema, Document } from 'mongoose';

//a typescript interface which is simply going to tell the type of an 
//object
//and here it extends or reuses the types of Document taken from mongoose
export interface User extends Document{
    username: string;
    email: string;
    password: string;
    verifyCode: string;
    verifyCodeExpiry: Date;
    isVerified: boolean;
}

//now lets create the mongoose schema which simply assigns the type of
// the specific
//objects to be stored in an array and stored under the Model name in 
//the database 
const UserSchema: Schema<User> = new mongoose.Schema({
    username: {
        type: String,
        required: [true, "Username is required"],
        trim: true,
        unique:true
    },
    email: {
        type: String,
        required: [true, 'Email is required'],
        unique: true,
        match: [/.+\@.+\..+/, 'Please use a valid email address'],
      },
      password: {
        type: String,
        required: [true, 'Password is required'],
      },
      verifyCode: {
        type: String,
        required: [true, 'Verify Code is required'],
      },
      verifyCodeExpiry: {
        type: Date,
        required: [true, 'Verify Code Expiry is required'],
      },
      isVerified: {
        type: Boolean,
        default: false,
      },
})

//now we are reusing the User model NextJS might have earlier created 
//or if its the first time then create the User model
const UserModel =  (mongoose.models.User as mongoose.Model<User>) ||
  mongoose.model<User>('User', UserSchema);

//export it
export default UserModel;

Next, we will be creating some schemas using Zod. It's a typescript-first validator and is quite useful.

Creating Schemas using Zod

parent folder\schemas\signUpSchema.ts

import { z } from 'zod';

//here zod can be used to validate username whether it is a string,
//has min 2 characters,
//max 20 characters,
//and follows a regex (small task for you:chatgpt what this regex means)
export const usernameValidation = z
    .string()
    .min(2, "Username must have minimum 2 characters")
    .max(20, "Username must have a maximum of 20 characters")
    .regex(/^[a-zA-Z0-9_]+$/, 'Username must not contain special characters');

//here zod is used to validate the signup form whether the data from it
//has a username which is valid using the earlier validation
//,has email which follows the format of a mail and a string
//,has password with minimum 6 characters
export const signUpSchema = z.object({
        username: usernameValidation,

        email: z.string().email({ message: 'Invalid email address' }),
        password: z
          .string()
          .min(6, { message: 'Password must be at least 6 characters' }),
      });

parent folder\schemas\signInSchema.ts

import { z } from 'zod'

//verify the signIn form
export const signInSchema = z.object({
  identifier: z.string(),
  password: z.string(),
});

parent folder\schemas\verifySchema.ts

import { z } from 'zod';

//verify OTP
export const verifySchema = z.object({
  code: z.string().length(6, 'Verification code must be 6 digits'),
});

Next, we will establish the connection from our backend to our database. For this project, I will be obviously using MongoDB. It's popular and has a free cluster. To be specific, we will be using MongoDB Atlas or simply put, the cloud version.

Connecting to MongoDB Database

parent folder\lib\dbConnect.ts

import mongoose from 'mongoose';

//NextJS is edge timed fraemwork which means the database connection is not always
//there and when the request is made, then the connection is established , but
//sometimes the connection can still be there if regular requests are made and
//hence we will just in case check whether connection is there or not

//here we are defining the type of connection
type ConnectionObject = {
  isConnected?: number;
};

//we used the type ConnectionObject here
const connection: ConnectionObject = {};

//main asychronous function where the connection will be established
async function dbConnect(): Promise<void> {
  // Check if we have a connection to the database or if it's currently connecting
  if (connection.isConnected) {
    console.log('Already connected to the database');
    return;
  }

  try {
    // Attempt to connect to the database
    const db = await mongoose.connect(process.env.MONGODB_URI || '', {});

    connection.isConnected = db.connections[0].readyState;

    console.log('Database connected successfully');
  } catch (error) {
    console.error('Database connection failed:', error);

    // Graceful exit in case of a connection error
    process.exit(1);
  }
}

//export the function reference
export default dbConnect;

Now make sure to grab your MongoDB URL from the website and use it in your .env file.

parent\.env

MONGODB_URI=(your url)
๐Ÿ’ก
It is highly probable that in your .gitignore file, .env is not marked so make sure to add that so that on committing, your sensitive details don't get sent to your public repo.

Next, we will be dealing with some sign-up logic, and for user verification, we will set up email verification using Resend.

Setting Up Email Verification

First, we check whether the user exists or not by searching for the mail ID, in case he/she does not, we create a new entry in the database. In case he/she does, then we will need to check whether the user is verified or not, and if the user is, then simply tell the user that the username or email is already taken else we will need to verify the user.

For email verification, we will be using EmailJS. It has a free tier so don't worry. Create a template and make it look like this:

You will notice stuff like {{...}}. These are variables and dynamically given when we will be sending the request to their servers. Make sure to grab the template ID.

Now you will have to add an email service, preferably Gmail. Add it and grab the service ID.

Now from your account, grab the public key and the private key. Make sure to activate Allow EmailJS API from non-browser applications in the security panel. We will be sending requests from the server side.

parent folder\.env

MONGODB_URI=....
EMAILJS_SERVICE_ID=...
EMAILJS_TEMPLATE_ID=...
EMAILJS_PUBLIC_KEY=...
EMAILJS_PRIVATE_KEY=...

Next, install @emailjs/nodejs:

npm i @emailjs/nodejs

Okay, we are ready to write our code. We will be taking help from EmailJS docs so you might want to have a look into it.

Now we will need to add the type for the response from the API.

parent folder\types\ApiResponse.ts

//type of response
export interface ApiResponse {
  success: boolean;
  message: string;
};

Now, we will need to write the code for sending mail to the user.

parent folder\helpers\sendVerificationEmail.ts

import emailjs from '@emailjs/nodejs';
import { ApiResponse } from '@/types/ApiResponse';

export async function sendVerificationEmail(
  email: string,
  username: string,
  verifyCode: string
): Promise<ApiResponse> {
  try {

    await emailjs
      .send(
        process.env.EMAILJS_SERVICE_ID||"",
        process.env.EMAILJS_TEMPLATE_ID||"",
        {
          username:username,
          to_email: email,
          otp:verifyCode,
        },
        {
          publicKey: process.env.EMAILJS_PUBLIC_KEY||"",
          privateKey: process.env.EMAILJS_PRIVATE_KEY||"",
        }
      )
    return { success: true,message: 'Verification email sent successfully.'};
  } catch (emailError) {
    console.error('Error sending verification email:', emailError);
    return { success: false,message: 'Failed to send verification email.'};
  }
}

Now we will need to add the backend API for implementing the SignUp logic.
We will need a POST request handler for the API main-url/api/sign-up .where we will handle logic related to verification, hashing, and a lot of other things.

First, run this command:

npm i bcryptjs @types/bcryptjs

bcryptjs is used for encryption.

app\api\sign-up\route.ts

import dbConnect from "@/lib/dbConnect";
import UserModel from "@/model/User";
import bcrypt from "bcryptjs";
import { sendVerificationEmail } from "@/helpers/sendVerificationEmail";

export async function POST(request:Request) {
  //nextjs is edge timed so we will need to connect the database first
  await dbConnect();

  try {
    //we will get the user data from the request body
    const { username, email, password } = await request.json();

    //we are checking whether we can find any verified user based on username
    const existingVerifiedUserByUsername = await UserModel.findOne({
      username,
      isVerified: true,
    });

    if (existingVerifiedUserByUsername) {
      //in case we do we simply return back and respond with the fact
      //that the username is already taken
      return Response.json(
        {
          success: false,
          message: "Username is already taken",
        },
        { status: 400 },
      );
    }

    //if we don't find a verified user using username, we try it with email
    const existingUserByEmail = await UserModel.findOne({ email });

    //we prepare the OTP
    let verifyCode = Math.floor(100000 + Math.random() * 900000).toString();

    if (existingUserByEmail) {
      if (existingUserByEmail.isVerified) {
        //if we find verified user based on mail, we return back and respond
        //with email is already taken
        return Response.json(
          {
            success: false,
            message: "User already exists with this email",
          },
          { status: 400 },
        );
      } else {
        //if we find an user with the email, but h/she is not verifed then
        // we will send him/her the OTP in his/her mail so we will store all the
        //data in the database
        const hashedPassword = await bcrypt.hash(password, 10);
        existingUserByEmail.username = username;//hitesh sir missed this
        //the problem with the above line not being there is like:
        //the logic we are following in case there was an user who signed up but did  
        //not verify and now he signs up again but with different username,
        //since he did not verify last time so we update the details but if we do not 
        //update the username, then the verify page won't work, since it checks
        //the username and not the email, that would be a problem
        existingUserByEmail.password = hashedPassword; //storing the hashed password
        existingUserByEmail.verifyCode = verifyCode; //storing the OTP
        existingUserByEmail.verifyCodeExpiry = new Date(Date.now() + 3600000);
        //storing the time limit
        await existingUserByEmail.save(); //finally saving the updated user object
      }
    } else {
      //user doesn't exist at all so we will create a new user entry
      //in the database
      const hashedPassword = await bcrypt.hash(password, 10);
      const expiryDate = new Date();
      expiryDate.setHours(expiryDate.getHours() + 1);

      const newUser = new UserModel({
        username,
        email,
        password: hashedPassword,
        verifyCode,
        verifyCodeExpiry: expiryDate,
        isVerified: false,
        isAcceptingMessages: true,
        messages: [],
      });

      //saving the new user entry with all the data
      await newUser.save();
    }

    // finally sending verification email
    const emailResponse = await sendVerificationEmail(
      email,
      username,
      verifyCode,
    );
    if (!emailResponse.success) {
      //the email sending process was not successful
      return Response.json(
        {
          success: false,
          message: emailResponse.message,
        },
        { status: 500 },
      );
    }

    //email successfully sent
    return Response.json(
      {
        success: true,
        message: "User registered successfully. Please verify your account.",
      },
      { status: 201 },
    );
  } catch (error) {
    //some error occured in the entire process
    console.error("Error registering user:", error);
    return Response.json(
      {
        success: false,
        message: "Error registering user",
      },
      { status: 500 },
    );
  }
}

With this, we complete the Sign-Up part. Next, we will see the magic of next-auth. Basically, we have done the sign-up part ourselves but we will use the help of next-auth for the sign-in part.

Implementing Next-Auth

So first, install next-auth:

npm i next-auth
๐Ÿ’ก
It is recommended that you also follow the official docs along with this article.

Now we could definitely use third-party providers like Google or Github but for now, let's implement credentials providers(this is also the most complicated one).

app\api\auth\[...nextauth]\options.ts

import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import dbConnect from '@/lib/dbConnect';
import UserModel from '@/model/User';

export const authOptions: NextAuthOptions = {
    providers: [
      //first we will use the credential provider
    CredentialsProvider({
      id: 'credentials',
        name: 'Credentials',
      //telling what credentials we will use
      credentials: {
        email: { label: 'Email', type: 'text' },
        password: { label: 'Password', type: 'password' },
        },
      //defining the custom authorise function
        async authorize(credentials: any): Promise<any> {
          //connect to DB
        await dbConnect();
            try {
                //searching whether there's an user with username or email
                //same as credentials.identifier(basically the email)
          const user = await UserModel.findOne({
            $or: [
              { email: credentials.identifier },
              { username: credentials.identifier },
            ],
          });
                //user not found
          if (!user) {
            throw new Error('No user found with this email');
                }
                //user is not verified
          if (!user.isVerified) {
            throw new Error('Please verify your account before logging in');
                }
                //in case the user exists and is verified, then we will
                //check whether the password is correct or not
          const isPasswordCorrect = await bcrypt.compare(
            credentials.password,
            user.password
                );
                //if the password is correct, return the user
          if (isPasswordCorrect) {
            return user;
          } else //throw an error
          {
            throw new Error('Incorrect password');
          }
        } catch (err: any) {
          throw new Error(err);
        }
      },
    }),
    ],
    //here we are going to define the callbacks
    callbacks: {
      //the jwt strategy where a jwt token will be transferred to the browser
    async jwt({ token, user }) {
            if (user) {
                //we will store as much data in the token so that later we can 
                //retrieve the token and access a lot of data
        token._id = user._id?.toString(); // Convert ObjectId to string
        token.isVerified = user.isVerified;
        token.username = user.username;
      }
      return token;//return the token
        },
        //the session strategy
    async session({ session, token }) {
      if (token) {
        session.user._id = token._id;
        session.user.isVerified = token.isVerified;
        session.user.isAcceptingMessages = token.isAcceptingMessages;
        session.user.username = token.username;
      }
      return session;
    },
    },
    //strategy to use
  session: {
    strategy: 'jwt',
  },
  secret: process.env.NEXTAUTH_SECRET,
  pages: {
    signIn: '/sign-in',
  },
};

The above code was mainly related to the sign-in logic. But here you will obviously notice that there are some type errors. It is mainly due to that we are using custom user object but next-auth 's default type for user does not match it, rather some extra properties need to be there. So let's add them:

parent folder\types\next-auth.d.ts

import 'next-auth';

// here we will be adding the extra properties to the default types
declare module 'next-auth' {
  interface Session {
    user: {
      _id?: string;
      isVerified?: boolean;
      username?: string;
    } & DefaultSession['user'];
  }

  interface User {
    _id?: string;
    isVerified?: boolean;
    username?: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    _id?: string;
    isVerified?: boolean;
    username?: string;
  }
}

Plus we added a secret that we are using from the .env file:

MONGODB_URI=...
RESEND_API_KEY=...
NEXTAUTH_SECRET=...

Now let's complete the API by adding the route.ts file under [...nextauth] folder

app\api\auth\[...nextauth]\route.ts

import NextAuth from 'next-auth/next';
import { authOptions } from './options';
//getting the handler 
const handler = NextAuth(authOptions);
//assign it for both GET and POST request
export { handler as GET, handler as POST };

Now we will need to add a middleware (middleware is something that allows some function to run before moving on to handle some request to some route).

parent folder\middleware.ts

import { NextRequest, NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
export { default } from 'next-auth/middleware';

export const config = {
  matcher: [ '/quote/:path*', '/sign-in', '/sign-up', '/', '/verify/:path*'],
};

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });
  const url = request.nextUrl;

  // Redirect to home if the user is already authenticated
  // and trying to access sign-in, sign-up, or home page
  if (
    token &&
    (url.pathname.startsWith('/sign-in') ||
      url.pathname.startsWith('/sign-up') ||
      url.pathname.startsWith('/verify')||
      url.pathname === '/')
  ) {
    return NextResponse.redirect(new URL('/quote', request.url));
  }

  if (!token && url.pathname==='/quote') {
    return NextResponse.redirect(new URL('/sign-in', request.url));
  }

  return NextResponse.next();
}

Okay, now we will need to add more APIs. We need to add an API for checking whether the username is unique or not.

app\api\check-username-unique\route.ts

import dbConnect from "@/lib/dbConnect";
import UserModel from "@/model/User";
import { z } from "zod";
import { usernameValidation } from "@/schemas/signUpSchema";

//we are going to define a zod schema where an object with username as property
//should match the schema we defined earlier
const UsernameQuerySchema = z.object({
  username: usernameValidation,
});

//lets discuss some things before hand. The API will be:
//(BASE_URL)/api/check-username-unique but the actual link request is made will
//also have query parameter and hence it will be of format:
//(BASE_URL)/api/check-username-unique?username=.....
//since the username is included in the query parameter hence the request type
//can simply be GET
export async function GET(request: Request) {
  //connect to database
  await dbConnect();

  try {
    //extract the entire URL
    const { searchParams } = new URL(request.url);
    //extract the username value from query parameter and store it in object
    const queryParams = {
      username: searchParams.get("username"),
    };

    //try to pass the Zod validation
    const result = UsernameQuerySchema.safeParse(queryParams);

    if (!result.success) {
      //does not pass the zod validation hence simply pass the errors
      const usernameErrors = result.error.format().username?._errors || [];
      return Response.json(
        {
          success: false,
          message:
            usernameErrors?.length > 0
              ? usernameErrors.join(", ")
              : "Invalid query parameters",
        },
        { status: 400 },
      );
    }

    //zod validation passed
    const { username } = result.data;

    //check for existing user with the same username and is also verified
    const existingVerifiedUser = await UserModel.findOne({
      username,
      isVerified: true,
    });

    if (existingVerifiedUser) {
      //if verified user with same username exists then return false
      return Response.json(
        {
          success: false,
          message: "Username is already taken",
        },
        { status: 200 },
      );
    }

    //no verified user with same username present then we can say its unique
    return Response.json(
      {
        success: true,
        message: "Username is available",
      },
      { status: 200 },
    );
  } catch (error) {
    console.error("Error checking username:", error);
    return Response.json(
      {
        success: false,
        message: "Error checking username",
      },
      { status: 500 },
    );
  }
}

Next, we should think about making the API for verifying the OTP.

api\verify-code\route.ts

import dbConnect from "@/lib/dbConnect";
import UserModel from "@/model/User";

export async function POST(request:Request) {
  // Connect to the database
  await dbConnect();

  try {
    //get the username and the code from the request
    const { username, code } = await request.json();
    //decode the username in case its encrypted
    const decodedUsername = decodeURIComponent(username);
    //search for the user object with same username
    const user = await UserModel.findOne({ username: decodedUsername });

    if (!user) {
      //username not found
      return Response.json(
        { success: false, message: "User not found" },
        { status: 404 },
      );
    }

    // Check if the code is correct and not expired
    const isCodeValid = user.verifyCode === code;
    const isCodeNotExpired = new Date(user.verifyCodeExpiry) > new Date();

    if (isCodeValid && isCodeNotExpired) {
      // Update the user's verification status
      user.isVerified = true;
      //save the details for that user
      await user.save();

      return Response.json(
        { success: true, message: "Account verified successfully" },
        { status: 200 },
      );
    } else if (!isCodeNotExpired) {
      // Code has expired
      return Response.json(
        {
          success: false,
          message:
            "Verification code has expired. Please sign up again to get a new code.",
        },
        { status: 400 },
      );
    } else {
      // Code is incorrect
      return Response.json(
        { success: false, message: "Incorrect verification code" },
        { status: 400 },
      );
    }
  } catch (error) {
    console.error("Error verifying user:", error);
    return Response.json(
      { success: false, message: "Error verifying user" },
      { status: 500 },
    );
  }
}

So our backend is more or less done. Later we might need to make some changes. But for now, we will move to frontend.

Diving into the Frontend

We will be using shadcn for building our components. So first let's integrate it into our project:

npx shadcn-ui@latest init

I have chosen all default options during the installation. Feel free to choose your own.

Next, we will need to use the Form from shadcn . So let's add it as well:

npx shadcn-ui@latest add form

There are various other things that need to be set up before we move to the form creation.

First, add a toastershadcn

npx shadcn-ui@latest add toast

Next, add the <Toaster/> component in the layout.tsx

app\layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
      <Toaster />
    </html>
  );
}

We will also implement debouncing ( a functionality where when you are typing, no request is made until you pause typing for some time). For that, we will need to install usehooks-ts

npm install usehooks-ts

Also, add the input from shadcn

npx shadcn-ui@latest add input

One last thing is axios. We will be using this to make API calls.

npm i axios

Okay ig we are ready to create our Sign-Up Form.

app\(auth)\sign-up\page.tsx

"use client";

import { ApiResponse } from "@/types/ApiResponse";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useDebounceCallback } from "usehooks-ts";
import * as z from "zod";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import axios, { AxiosError } from "axios";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { signUpSchema } from "@/schemas/signUpSchema";

export default function SignUpForm() {
  //state for storing username
  const [username, setUsername] = useState("");
  //state for storing the message received from the backend regarding whether
  //username is unique or not
  const [usernameMessage, setUsernameMessage] = useState("");
  //state for storing boolean regarding whether backend has responded yet or not
  //helps in keeping the loader alive till then
  const [isCheckingUsername, setIsCheckingUsername] = useState(false);
  //similarly this is for the submition part
  const [isSubmitting, setIsSubmitting] = useState(false);
  //returns the debounced function which will update the username after 300ms and
  //if the user is typing, the value username needs to change to that value and the final value
  //it needs to change to keeps getting updated due to typing but once the typing stops for more than 300ms
  //, the state's value changes to the final value and triggers a re-render
  const debounced = useDebounceCallback(setUsername, 300);

  const router = useRouter();
  const { toast } = useToast();

  const form = useForm<z.infer<typeof signUpSchema>>({
    //uses Zod resolver using the signUpSchema we made earlier
    resolver: zodResolver(signUpSchema),
    defaultValues: {
      username: "",
      email: "",
      password: "",
    },
  });

  useEffect(() => {
    const checkUsernameUnique = async () => {
      //since anyways the username is getting updated after 300ms so
      if (username) {
        //mark the start of the loader
        setIsCheckingUsername(true);
        setUsernameMessage(""); // Reset message
        try {
          //make a call to the API
          const response = await axios.get<ApiResponse>(
            `/api/check-username-unique?username=${username}`
          );
          //get the message
          setUsernameMessage(response.data.message);
        } catch (error) {
          const axiosError = error as AxiosError<ApiResponse>;
          setUsernameMessage(
            axiosError.response?.data.message ?? "Error checking username"
          );
        } finally {
          //either it works fine or error occurs, we gotta end the loader
          setIsCheckingUsername(false);
        }
      }
    };
    checkUsernameUnique();
  }, [username]);

  const onSubmit = async (data: z.infer<typeof signUpSchema>) => {
    //start the loader
    setIsSubmitting(true);
    try {
      //make the POST request to the API with the data
      const response = await axios.post<ApiResponse>("/api/sign-up", data);

      toast({
        title: "Success",
        description: response.data.message,
      });

      //on success, go for verification
      router.replace(`/verify/${username}`);

      //end the loader
      setIsSubmitting(false);
    } catch (error) {
      console.error("Error during sign-up:", error);

      const axiosError = error as AxiosError<ApiResponse>;

      // Default error message
      let errorMessage = axiosError.response?.data.message;
      ("There was a problem with your sign-up. Please try again.");

      toast({
        title: "Sign Up Failed",
        description: errorMessage,
        variant: "destructive",
      });

      //end the loader
      setIsSubmitting(false);
    }
  };

  return (
    <div className="flex justify-center items-center min-h-screen">
      <div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
        <div className="text-center">
          <h1 className="text-3xl font-extrabold tracking-tight lg:text-5xl mb-6">
            Join the Community
          </h1>
          <p className="mb-4">Sign up to get started</p>
        </div>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
            <FormField
              name="username"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Username</FormLabel>
                  <Input
                    {...field}
                    onChange={(e: any) => {
                      field.onChange(e);
                      debounced(e.target.value);
                    }}
                  />
                  {isCheckingUsername && <Loader2 className="animate-spin" />}
                  {!isCheckingUsername && usernameMessage && (
                    <p
                      className={`text-sm ${
                        usernameMessage === "Username is available"
                          ? "text-green-500"
                          : "text-red-500"
                      }`}
                    >
                      {usernameMessage}
                    </p>
                  )}
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              name="email"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <Input {...field} name="email" />
                  <p className="text-muted text-gray-400 text-sm">
                    We will send you a verification code
                  </p>
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              name="password"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <Input type="password" {...field} name="password" />
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit" className="w-full" disabled={isSubmitting}>
              {isSubmitting ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  Please wait
                </>
              ) : (
                "Sign Up"
              )}
            </Button>
          </form>
        </Form>
        <div className="text-center mt-4">
          <p>
            Already a member?{" "}
            <Link href="/sign-in" className="text-blue-600 hover:text-blue-800">
              Sign in
            </Link>
          </p>
        </div>
      </div>
    </div>
  );
}

And now let's write the code for the page where we will enter the OTP and verify it.

app\(auth)\verify\[username]\page.tsx

'use client';

import { Button } from '@/components/ui/button';
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast';
import { ApiResponse } from '@/types/ApiResponse';
import { zodResolver } from '@hookform/resolvers/zod';
import axios, { AxiosError } from 'axios';
import { useParams, useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { verifySchema } from '@/schemas/verifySchema';

export default function VerifyAccount() {
    const router = useRouter();
    //as you might have noticed the folder structure, [username] means
    //it can be any value, lets say the link is (base_url)/app/verify/mainak
    //so for that username is "mainak" and can be accessed using params
  const params = useParams<{ username: string }>();
  const { toast } = useToast();
    const form = useForm<z.infer<typeof verifySchema>>({
      //using the verifySchema for the zod resolver
    resolver: zodResolver(verifySchema),
  });

  const onSubmit = async (data: z.infer<typeof verifySchema>) => {
    try {
      const response = await axios.post<ApiResponse>(`/api/verify-code`, {
        username: params.username,
        code: data.code,
      });

      toast({
        title: 'Success',
        description: response.data.message,
      });

      router.replace('/sign-in');
    } catch (error) {
      const axiosError = error as AxiosError<ApiResponse>;
      toast({
        title: 'Verification Failed',
        description:
          axiosError.response?.data.message ??
          'An error occurred. Please try again.',
        variant: 'destructive',
      });
    }
  };

  return (
    <div className="flex justify-center items-center min-h-screen bg-gray-100">
      <div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
        <div className="text-center">
          <h1 className="text-3xl font-extrabold tracking-tight lg:text-5xl mb-6">
            Verify Your Account
          </h1>
          <p className="mb-4">Enter the verification code sent to your email</p>
        </div>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
            <FormField
              name="code"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Verification Code</FormLabel>
                  <Input {...field} />
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit">Verify</Button>
          </form>
        </Form>
      </div>
    </div>
  );
}

Next, we are going to implement the SIgn-In page. We will be using signIn() from next-auth but for that to work we will need to wrap the component with SessionProvider. So first let's deal with this:

parent folder\context\AuthProvider.tsx

'use client';

import { SessionProvider } from 'next-auth/react';

export default function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <SessionProvider>
      {children}
    </SessionProvider>
  );
}

Now let's wrap the AuthProvider around the JSX in layout.tsx

app\layout.tsx

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import AuthProvider from '../context/AuthProvider';
import { Toaster } from '@/components/ui/toaster';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Authentication in NextJS',
};

interface RootLayoutProps {
  children: React.ReactNode;
}

export default async function RootLayout({ children }: RootLayoutProps) {
  return (
    <html lang="en" >
      <AuthProvider>
        <body className={inter.className}>
          {children}
          <Toaster />
        </body>
      </AuthProvider>
    </html>
  );
}

Okay so now let's complete the Sign-In page.

app\(auth)\sign-in\page.tsx

'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { signIn } from 'next-auth/react';
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useToast } from '@/components/ui/use-toast';
import { signInSchema } from '@/schemas/signInSchema';

export default function SignInForm() {
  const router = useRouter();

    const form = useForm<z.infer<typeof signInSchema>>({
      //passing the signInSchema for the zod resolver
    resolver: zodResolver(signInSchema),
    defaultValues: {
      identifier: '',
      password: '',
    },
  });

  const { toast } = useToast();
  const onSubmit = async (data: z.infer<typeof signInSchema>) => {
    const result = await signIn('credentials', {
      redirect: false,
      identifier: data.identifier,
      password: data.password,
    });

    if (result?.error) {
      if (result.error === 'CredentialsSignin') {
        toast({
          title: 'Login Failed',
          description: 'Incorrect username or password',
          variant: 'destructive',
        });
      } else {
        toast({
          title: 'Error',
          description: result.error,
          variant: 'destructive',
        });
      }
    }

    if (result?.url) {
      router.replace('/');
    }
  };

  return (
    <div className="flex justify-center items-center min-h-screen bg-gray-800">
      <div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
        <div className="text-center">
          <h1 className="text-4xl font-extrabold tracking-tight lg:text-5xl mb-6">
            Welcome Back 
          </h1>
          <p className="mb-4">Sign in to continue your progress</p>
        </div>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
            <FormField
              name="identifier"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email/Username</FormLabel>
                  <Input {...field} />
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              name="password"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <Input type="password" {...field} />
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button className='w-full' type="submit">Sign In</Button>
          </form>
        </Form>
        <div className="text-center mt-4">
          <p>
            Not a member yet?{' '}
            <Link href="/sign-up" className="text-blue-600 hover:text-blue-800">
              Sign up
            </Link>
          </p>
        </div>
      </div>
    </div>
  );
}

Now let's build the Home page (basically available at (base_url)/ ).

app\page.tsx

import Image from "next/image";

export default function Home() {
  return (
    <div className="main h-screen flex justify-center px-[10vw] items-center">
      <div className="text-4xl text-gray-200">
        Want to know the <span className="text-cyan-500">Quote</span> of the day?
      </div>
    </div>
  );
}

Just came up with something to display on the landing page. Feel free to modify it according to your needs. Now let's build the main page which you appear at after completing the Sign-In process.

app\quote\page.ts

import Image from "next/image";

export default function Quote() {
  return (
    <div className="main h-screen flex justify-center px-[10vw] items-center">
      <div className="text-4xl text-gray-200">
        Make sure to keep yourself <span className="text-cyan-500">Hydrated</span>
      </div>
    </div>
  );
}

Again just got something to put on the page.

But one thing we are forgetting. The <Navbar/> , I mean we need it to be there on all pages so that we can show the option to logout, and once you are signed out, it should show the Login option.

parent folder\components\Navbar.tsx

'use client'

import React from 'react';
import Link from 'next/link';
import { useSession, signOut } from 'next-auth/react';
import { Button } from './ui/button';
import { User } from 'next-auth';

function Navbar() {
  const { data: session } = useSession();
  const user : User = session?.user;

  return (
    <nav className="p-4 md:p-6 shadow-md text-white fixed w-full bg-white bg-opacity-5">
      <div className="container mx-auto flex flex-col md:flex-row justify-between items-center">
        <a href="#" className="text-3xl font-bold mb-4 md:mb-0">
          Quoter
        </a>
        {session ? (
          <>
            <span className="mr-4 text-xl">
              Welcome, {user.username || user.email}
            </span>
            <Button onClick={() => signOut()} className="w-full md:w-auto bg-slate-100 text-black text-lg" variant='outline'>
              Logout
            </Button>
          </>
        ) : (
          <Link href="/sign-in">
            <Button className="w-full md:w-auto bg-slate-100 text-black text-lg" variant={'outline'}>Login</Button>
          </Link>
        )}
      </div>
    </nav>
  );
}

export default Navbar;

But we need it at the top level, right? So that it appears on all pages. So ig I will put it at layout.tsx.

app\layout.tsx

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import AuthProvider from '../context/AuthProvider';
import { Toaster } from '@/components/ui/toaster';
import Navbar from '@/components/Navbar';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Authentication in NextJS',
};

interface RootLayoutProps {
  children: React.ReactNode;
}

export default async function RootLayout({ children }: RootLayoutProps) {
  return (
    <html lang="en" >
      <AuthProvider>
        <body className={inter.className}>
          <Navbar/>
          <div className="min-h-screen  bg-gray-900">
            {children}
            </div>
          <Toaster />
        </body>
      </AuthProvider>
    </html>
  );
}
๐Ÿ’ก
And here comes a problem. You will find the navbar appearing on even sign-in and sign-up pages which does not make sense, right? Now what we will do is club together the page.tsx file directly under app folder and the quote folder inside (app). We all know that the URL won't change since (...) means we are using it for grouping. Here we will make another layout.tsx where the <Navbar/> will be there and remove it from the one present directly under app .Hence we would be only applying the navbar to the home page and main page.

Cut app\page.tsx and paste it inside app\(app) .Cut app\quote and paste it inside app\(app).

app\(app)\layout.tsx


import Navbar from '@/components/Navbar';

interface AppLayoutProps {
  children: React.ReactNode;
}

export default async function AppLayout({ children }: AppLayoutProps) {
  return (
    <>
          <Navbar/>
            {children}
    </>
  );
}

Remove <Navbar/> from app\layout.tsx and its import.

And that's how we finally completed our project. We have integrated complete authentication in our NextJS app. Though there are further improvements possible, like adding third-party providers and all, but still it looks great.

Bonus Content: Adding Third Party to the Party

Okay so now we would add third-party identity providers to our app. I will be adding Github and Google. But for that we will need to grab client ID and client secret from their OAuth pages.

For Github, move to this page. Register a new OAuth app. Homepage URL will be in this format:base-url . For testing in dev mode, it will be like http://localhost:3000 . But for running on the app in production mode, give it the production URL. Authorization Callback URL will be of format: (base-url)/api/auth/callback/github where give the base URL accordingly. Now on creation, grab the client ID and client secret.

For Google, move to this page. The process is similar, just the authorization callback URL this time will be (base-url)/api/auth/callback/google .

Okay now let's take care of the models. We will be storing users logged in through Google or GitHub under different tables but in the same database.

Okay so now your .env should be looking like this:

MONGODB_URI=
EMAILJS_SERVICE_ID=
EMAILJS_TEMPLATE_ID=
EMAILJS_PUBLIC_KEY=
EMAILJS_PRIVATE_KEY=
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_ID=
GITHUB_SECRET=

parent folder\model\GoogleUser.ts

import mongoose, { Schema, Document } from "mongoose";

interface GoogleUser extends Document {
    username: string;
    email: string;
    // Add any other fields here if needed
}

const GoogleUserSchema: Schema = new Schema({
    username: {
        type: String,
        required: [true, "Username is required"],
        trim: true,
        unique: true
    },
    email: {
        type: String,
        required: [true, 'Email is required'],
        unique: true,
        match: [/.+\@.+\..+/, 'Please use a valid email address'],
    },
    //we can also take the profile image from user's google account
});

const GoogleUser = (mongoose.models.GoogleUser as mongoose.Model<GoogleUser>) ||
    mongoose.model<GoogleUser>("GoogleUser", GoogleUserSchema);

export default GoogleUser;

parent folder\model\GithubUser.ts

import mongoose, { Schema, Document } from "mongoose";

interface GithubUser extends Document {
    username: string;
    // Add any other fields here if needed
}

const GithubUserSchema: Schema = new Schema({
    username: {
        type: String,
        required: [true, "Username is required"],
        trim: true,
        unique: true
    },
    email: {
        type: String,
        required: [true, 'Email is required'],
        unique: true,
        match: [/.+\@.+\..+/, 'Please use a valid email address'],
    },

    //we can also take the profile image from user's Github account
});

const GithubUser = (mongoose.models.GithubUser as mongoose.Model<GithubUser>) ||
    mongoose.model<GithubUser>("GithubUser", GithubUserSchema);

export default GithubUser;

Now lets add the providers and make some changes to the callbacks in the options.ts file.

app\api\auth\[...nextauth]\options.ts

import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from "next-auth/providers/google";
import GitHubProvider from "next-auth/providers/github";
import bcrypt from 'bcryptjs';
import dbConnect from '@/lib/dbConnect';
import UserModel from '@/model/User';
import GoogleUser from '@/model/GoogleUser';
import GithubUser from '@/model/GithubUser';

export const authOptions: NextAuthOptions = {
    providers: [
      //first we will use the credential provider
    CredentialsProvider({
      id: 'credentials',
        name: 'Credentials',
      //telling what credentials we will use
      credentials: {
        email: { label: 'Email', type: 'text' },
        password: { label: 'Password', type: 'password' },
        },
      //defining the custom authorise function
        async authorize(credentials: any): Promise<any> {
          //connect to DB
        await dbConnect();
            try {
                //searching whether there's an user with username or email
                //same as credentials.identifier(basically the email)
          const user = await UserModel.findOne({
            $or: [
              { email: credentials.identifier },
              { username: credentials.identifier },
            ],
          });
                //user not found
          if (!user) {
            throw new Error('No user found with this email');
                }
                //user is not verified
          if (!user.isVerified) {
            throw new Error('Please verify your account before logging in');
                }
                //in case the user exists and is verified, then we will
                //check whether the password is correct or not
          const isPasswordCorrect = await bcrypt.compare(
            credentials.password,
            user.password
                );
                //if the password is correct, return the user
          if (isPasswordCorrect) {
            return user;
          } else //throw an error
          {
            throw new Error('Incorrect password');
          }
        } catch (err: any) {
          throw new Error(err);
        }
      },
    }),
    //lets use the google provider
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID||"",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET||"",
    }),
    //lets use the github provider
    GitHubProvider({
      clientId: process.env.GITHUB_ID||"",
      clientSecret: process.env.GITHUB_SECRET||"",
    })
    ],
    //here we are going to define the callbacks
  callbacks: {
    async signIn({ user, account }) {
       //if logged in using google then save the user under github users in 
      //database if not earlier created
      if (account?.provider === "google") {
        const { name, email } = user;
        try {
          //connect to DB
          await dbConnect();
          const userExists = await GoogleUser.findOne({ email });

          if (!userExists) {
            await GoogleUser.create({ name, email });
          }
        } catch (error) {
          console.log(error);
        }
      }
      //if logged in using github then save the user under github users in 
      //database if not earlier created
      if (account?.provider === "github") {
        const { name, email } = user;
        try {
          //connect to DB
          await dbConnect();
          const userExists = await GithubUser.findOne({ email });

          if (!userExists) {
            await GithubUser.create({ name, email });
          }
        } catch (error) {
          console.log(error);
        }
      }

      return true;
    },
      //the jwt strategy where a jwt token will be transferred to the browser
      async jwt({ token, user, account }) {
        if (user) {
          if (account?.provider === "google") {
            //if logged in using google
            token.username = user.name||"";
          }
          else if (account?.provider === "github") {
            //if logged in using github
            token.username = user.name||"";
          }
          else {
            //we will store as much data in the token so that later we can 
            //retrieve the token and access a lot of data
            token._id = user._id?.toString(); // Convert ObjectId to string
            token.isVerified = user.isVerified;
            token.username = user.username;
          }
        }
      return token;//return the token
        },
        //the session strategy
    async session({ session, token }) {
      if (token) {
        session.user._id = token._id;
        session.user.isVerified = token.isVerified;
        session.user.username = token.username;
      }
      return session;
    },
    },
    //strategy to use
  session: {
    strategy: 'jwt',
  },
  secret: process.env.NEXTAUTH_SECRET,
  pages: {
    signIn: '/sign-in',
  },
};

Now lets make some changes to the frontend, shall we?

app\(auth)\sign-up\page.tsx

"use client";

import { ApiResponse } from "@/types/ApiResponse";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useDebounceCallback } from "usehooks-ts";
import * as z from "zod";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import axios, { AxiosError } from "axios";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { signUpSchema } from "@/schemas/signUpSchema";
import Image from "next/image";
import { signIn } from "next-auth/react";

export default function SignUpForm() {
  //state for storing username
  const [username, setUsername] = useState("");
  //state for storing the message received from the backend regarding whether
  //username is unique or not
  const [usernameMessage, setUsernameMessage] = useState("");
  //state for storing boolean regarding whether backend has responded yet or not
  //helps in keeping the loader alive till then
  const [isCheckingUsername, setIsCheckingUsername] = useState(false);
  //similarly this is for the submition part
  const [isSubmitting, setIsSubmitting] = useState(false);
  //returns the debounced function which will update the username after 300ms and
  //if the user is typing, the value username needs to change to that value and the final value
  //it needs to change to keeps getting updated due to typing but once the typing stops for more than 300ms
  //, the state's value changes to the final value and triggers a re-render
  const debounced = useDebounceCallback(setUsername, 300);

  const router = useRouter();
  const { toast } = useToast();

  const form = useForm<z.infer<typeof signUpSchema>>({
    //uses Zod resolver using the signUpSchema we made earlier
    resolver: zodResolver(signUpSchema),
    defaultValues: {
      username: "",
      email: "",
      password: "",
    },
  });

  useEffect(() => {
    const checkUsernameUnique = async () => {
      //since anyways the username is getting updated after 300ms so
      if (username) {
        //mark the start of the loader
        setIsCheckingUsername(true);
        setUsernameMessage(""); // Reset message
        try {
          //make a call to the API
          const response = await axios.get<ApiResponse>(
            `/api/check-username-unique?username=${username}`
          );
          //get the message
          setUsernameMessage(response.data.message);
        } catch (error) {
          const axiosError = error as AxiosError<ApiResponse>;
          setUsernameMessage(
            axiosError.response?.data.message ?? "Error checking username"
          );
        } finally {
          //either it works fine or error occurs, we gotta end the loader
          setIsCheckingUsername(false);
        }
      }
    };
    checkUsernameUnique();
  }, [username]);

  const onSubmit = async (data: z.infer<typeof signUpSchema>) => {
    //start the loader
    setIsSubmitting(true);
    try {
      //make the POST request to the API with the data
      const response = await axios.post<ApiResponse>("/api/sign-up", data);

      toast({
        title: "Success",
        description: response.data.message,
      });

      //on success, go for verification
      router.replace(`/verify/${username}`);

      //end the loader
      setIsSubmitting(false);
    } catch (error) {
      console.error("Error during sign-up:", error);

      const axiosError = error as AxiosError<ApiResponse>;

      // Default error message
      let errorMessage = axiosError.response?.data.message;
      ("There was a problem with your sign-up. Please try again.");

      toast({
        title: "Sign Up Failed",
        description: errorMessage,
        variant: "destructive",
      });

      //end the loader
      setIsSubmitting(false);
    }
  };

  return (
    <div className="flex justify-center items-center min-h-screen">
      <div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
        <div className="text-center">
          <h1 className="text-2xl font-extrabold tracking-tight lg:text-4xl mb-2">
            Join the Community
          </h1>
          <p className="mb-4">Sign up to get started</p>
        </div>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
            <FormField
              name="username"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Username</FormLabel>
                  <Input
                    {...field}
                    onChange={(e: any) => {
                      field.onChange(e);
                      debounced(e.target.value);
                    }}
                  />
                  {isCheckingUsername && <Loader2 className="animate-spin" />}
                  {!isCheckingUsername && usernameMessage && (
                    <p
                      className={`text-sm ${
                        usernameMessage === "Username is available"
                          ? "text-green-500"
                          : "text-red-500"
                      }`}
                    >
                      {usernameMessage}
                    </p>
                  )}
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              name="email"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <Input {...field} name="email" />
                  {/* <p className="text-green-400 text-sm">
                    We will send you a verification code
                  </p> */}
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              name="password"
              control={form.control}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <Input type="password" {...field} name="password" />
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit" className="w-full" disabled={isSubmitting}>
              {isSubmitting ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  Please wait
                </>
              ) : (
                "Sign Up"
              )}
            </Button>
          </form>
        </Form>
        <div className="w-full flex justify-evenly items-center">
          <div className="w-[40%] h-[2px] bg-black" />
          <div className="text-lg">OR</div>
          <div className="w-[40%] h-[2px] bg-black"/>
        </div>
        <div className="third-party flex justify-center gap-x-[25%]">
          <button onClick={() => signIn("github")}>
          <Image width="64" height="64" src="https://img.icons8.com/sf-black-filled/64/github.png" alt="github"/>
          </button>
          <button onClick={() => signIn("google")}>
          <Image width="52" height="52" src="https://img.icons8.com/color/48/google-logo.png" alt="google-logo"/>
          </button>
        </div>
        <div className="text-center mt-4">
          <p>
            Already a member?{" "}
            <Link href="/sign-in" className="text-blue-600 hover:text-blue-800">
              Sign in
            </Link>
          </p>
        </div>
      </div>
    </div>
  );
}

For using the icons, make this small change:

parent folder\next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
    images: {
        remotePatterns: [
          {
            protocol: 'https',
            hostname: 'img.icons8.com',
          },
        ],
      },
};

export default nextConfig;

Okay so that's it with the bonus content, enjoy!

Wrapping Up

Man, this one was a really long one. I hope you learned a lot from this article and that now there is nothing to stop you from building your own full-fledged app with proper authentication involved. With this, thank you all for reading and we will meet again!

ย