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.
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
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)
.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
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>
);
}
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!