Next.js Demystified: User Authentication with NextJS & MongoDB
Securing your Next.js applications is a crucial step in delivering a seamless and safe user experience. We’ll guide you through the process of implementing user authentication, login, and signup functionalities using Next.js in conjunction with MongoDB.
We will need some libraries for this:
npm i axios bcryptjs jsonwebtoken nodemailer mongoose
Now, to connect our app to the MongoDB database, create an account in MongoDB Atlas and copy the connection string from MongoDB compass, and paste that in .env
file
MONGO_URI=mongodb+srv://<project_name>:<password>@cluster0.nwxsjhi.mongodb.net/
domain=http://localhost:3000
We are writing a code to export an asynchronous function named connect
. This function will establish a connection to a MongoDB database.
// src/dbConfig/dbConfig.ts
import mongoose from 'mongoose';
export async function connect() {
try{
mongoose.connect(process.env.MONGO_URI!);
const connection = mongoose.connection;
connection.on('connected', () => {
console.log("MongoDB connected successfully');
})
connection.on('error', (err) => {
console.log('MongoDB connection error' + err);
process.exit();
})
} catch (error) {
console.log(error);
}
}
We have connected our backend to MongoDB.
1) Signup
Now that we have laid the groundwork by establishing a connection to MongoDB using Next.js, it’s time to dive into the exciting process of building the signup frontend for our web application.
Frontend
src/app/signup/page.tsx
"use client";
import Link from "next/link";
import React, { useEffect } from "react";
import {useRouter} from "next/navigation";
import axios from "axios";
export default function SignupPage() {
const router = useRouter();
const [user, setUser] = React.useState({
email: "",
password: "",
username: "",
})
const onSignup = async () => {
try {
const response = await axios.post("/api/users/signup", user);
router.push("/login");
} catch (error:any) {
console.log("Signup failed", error.message);
}
}
return (
<div>
<label htmlFor="username">username</label>
<input
id="username"
type="text"
value={user.username}
onChange={(e) => setUser({...user, username: e.target.value})}
placeholder="username"
/>
<label htmlFor="email">email</label>
<input
id="email"
type="text"
value={user.email}
onChange={(e) => setUser({...user, email: e.target.value})}
placeholder="email"
/>
<label htmlFor="password">password</label>
<input
id="password"
type="password"
value={user.password}
onChange={(e) => setUser({...user, password: e.target.value})}
placeholder="password"
/>
<button onClick={onSignup}>Sign Up</button>
<Link href="/login">Visit login page</Link>
</div>
)
}
onSignup
is an asynchronous function that is called when the signup button is clicked.- It uses
axios.post
to make a POST request to "/api/users/signup" with the user data, we are getting username, email, and password from each input field, with setState hook, setting each field in user variable. - If the request is successful, it redirects the user to the “/login” page using
router.push
.
Creating a User Model
Now to store user data in MongoDB, we will create a user model first.
// src/models/userModel.js
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
username: {
type: String,
required: [true, "Please provide username"],
unique: true,
},
email: {
type: String,
required: [true, "Please provide email"],
unique: true,
},
password: {
type: String,
required: [true, "Please provide a password"],
},
isVerified: {
type: Boolean,
default: false,
},
isAdmin: {
type: Boolean,
default: false,
},
forgotPasswordToken: String,
forgotPasswordTokenExpiry: Date,
verifyToken: String,
verifyTokenExpiry: Date,
})
const User = mongoose.models.users || mongoose.model("users", userSchema);
export default User;
In nextjs, we connect each time with MongoDB, because of that we are using this syntax — const User = mongoose.models.users || mongoose.model (“users”, userSchema);
Backend
To work on the signup backend for your Next.js project, you’ll need to create an API route that handles the signup logic.
// app/api/users/signup/route.ts
import {connect} from "@/dbConfig/dbConfig";
import User from "@/models/userModel";
import { NextRequest, NextResponse } from "next/server";
import bcryptjs from "bcryptjs";
connect()
// Calls the connect function to establish a connection to the database.
export async function POST(request: NextRequest){
// Defines an asynchronous POST request handler.
try {
const reqBody = await request.json()
const {username, email, password} = reqBody
// Parses the request body to extract username, email, and password.
//Checks if a user with the provided email already exists.
const user = await User.findOne({email})
//If yes, returns a 400 response.
if(user){
return NextResponse.json({error: "User already exists"}, {status: 400})
}
//hash password using bcryptjs.
const salt = await bcryptjs.genSalt(10)
const hashedPassword = await bcryptjs.hash(password, salt)
const newUser = new User({
username,
email,
password: hashedPassword
})
// Saves the new user to the database.
const savedUser = await newUser.save()
return NextResponse.json({
message: "User created successfully",
success: true,
savedUser
})
} catch (error: any) {
return NextResponse.json({error: error.message}, {status: 500})
}
}
2) Login
Now we will see how login will work. Similar to Signup, we will collect email and password from input fields, and store that in user
and make a POST request to the backend with that data.
Frontend
// src/app/login/page.tsx
"use client";
import Link from "next/link";
import React, { useEffect } from "react";
import {useRouter} from "next/navigation";
import axios from "axios";
export default function SignupPage() {
const router = useRouter();
const [loading, setLoading] = React.useState(false);
const [user, setUser] = React.useState({
email: "",
password: "",
})
const onLogin = async () => {
try {
setLoading(true);
const response = await axios.post("/api/users/login", user);
router.push("/");
} catch (error:any) {
console.log("Login failed", error.message);
}finally {
setLoading(false);
}
}
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<h1>{loading ? "Processing" : "Login"}</h1>
<hr />
<label htmlFor="email">email</label>
<input
id="email"
type="text"
value={user.email}
onChange={(e) => setUser({...user, email: e.target.value})}
placeholder="email"
/>
<label htmlFor="password">password</label>
<input
id="password"
type="password"
value={user.password}
onChange={(e) => setUser({...user, password: e.target.value})}
placeholder="password"
/>
<button
onClick={onLogin}
<Link href="/signup">Visit signup page</Link>
</div>
)
}
Backend
// app/api/users/login/route.ts
import {connect} from "@/dbConfig/dbConfig";
import User from "@/models/userModel";
import { NextRequest, NextResponse } from "next/server";
import bcryptjs from "bcryptjs";
import jwt from "jsonwebtoken"
connect()
// Calls the connect function to establish a connection to the database.
export async function POST(request: NextRequest){
try {
const reqBody = await request.json()
const {email, password} = reqBody
//check if user exists
const user = await User.findOne({email})
if(!user){
return NextResponse.json({error: "User does not exist"}, {status: 400})
}
//check if password is correct
const validPassword = await bcryptjs.compare
(password, user.password)
if(!validPassword){
return NextResponse.json({error: "Invlid password"}, {status: 400})
}
//create token data
// A JavaScript object (tokenData) is created to store essential user
// information. In this case, it includes the user's unique identifier (id),
// username, and email.
const tokenData = {
id: user._id,
username: user.username,
email: user.email
}
// Create a token with expiration of 1 day
const token = await jwt.sign(tokenData, process.env.TOKEN_SECRET!, {expiresIn: "1d"})
// Create a JSON response indicating successful login
const response = NextResponse.json({
message: "Login successful",
success: true,
})
// Set the token as an HTTP-only cookie
response.cookies.set("token", token, {
httpOnly: true,
})
return response;
} catch (error: any) {
return NextResponse.json({error: error.message}, {status: 500})
}
}
Why Create Tokens:
- User Authentication:
Tokens are commonly used for user authentication. After a user successfully logs in, a token is generated and sent to the client.The client includes this token in the headers of subsequent requests, allowing the server to identify and authenticate the user without the need for the user to resend credentials with each request.
- Security:
Tokens are signed with a secret key, providing a level of security. This signature allows the server to verify that the token has not been tampered with.
3) Logout
Now to logout the user session, we will make a GET request to the backend.
Frontend
const logout = async () => {
try {
await axios.get('/api/users/logout');
router.push('/login')
} catch (error: any) {
console.log(error.message)
}
}
Backend
// This function handles HTTP GET requests to the API route.
export async function GET(request: NextRequest) {
try {
const response = NextResponse.json(
{
message: "Logout successful",
success: true,
}
)
response.cookies.set("token", "",
{ httpOnly: true, expires: new Date(0)
})
return response;
} catch (error : any) {
return NextResponse.json({ error: error.message},
{status: 500});
}
}
To perform the logout, it clears the token cookie by setting an empty value with an expiration date in the past.
- The
httpOnly: true
option ensures that the cookie is only accessible on the server side and not by client-side JavaScript, enhancing security. - The
expires: new Date(0)
option sets the expiration date of the cookie to a date in the past, effectively deleting it.
Middleware
Now to work on Route protection, not letting the user visit the profile page if there is no cookie, and not letting the user visit the log-in and sign-up page if there is a cookie.
// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const path = request.nextUrl.pathname
// Define paths that are considered public (accessible without a token)
const isPublicPath = path === '/login' || path === '/signup' || path === '/verifyemail'
// Get the token from the cookies
const token = request.cookies.get('token')?.value || ''
// Redirect logic based on the path and token presence
if(isPublicPath && token) {
// If trying to access a public path with a token, redirect to the home page
return NextResponse.redirect(new URL('/', request.nextUrl))
}
// If trying to access a protected path without a token, redirect to the login page
if (!isPublicPath && !token) {
return NextResponse.redirect(new URL('/login', request.nextUrl))
}
}
// It specifies the paths for which this middleware should be executed.
// In this case, it's applied to '/', '/profile', '/login', and '/signup'.
export const config = {
matcher: [
'/',
'/profile',
'/login',
'/signup',
'/verifyemail'
]
}
This middleware helps enforce access control by redirecting users based on their authentication status when trying to access specific routes.
Accessing cookie data
Now we will try to get user data that we stored in cookies using the JWT token.
We will make a helper function to get the token and decode it.
// helper/getDataFromToken.ts
import jwt from 'jsonwebtoken'
import { NextRequest } from "next/server"
export const getDataFromToken = (request: NextRequest) => {
try {
// Retrieve the token from the cookies
const token = request.cookies.get("token")?.value || '';
// Verify and decode the token using the secret key
const decodedToken:any = jwt.verify(token, process.env.TOKEN_SECRET!);
// Return the user ID from the decoded token
return decodedToken.id;
} catch (error: any) {
throw new Error(error.message)
}
}
This function is useful when you need to extract information about the authenticated user from the JWT stored in the cookie. It specifically retrieves the user ID from the token.
In frontend to get the user ID, we will make a GET request to the backend.
Frontend
// src/app/profile/page.ts
export default function ProfilePage() {
const router = useRouter()
const [data, setData] = useState("nothing")
const getUserDetails = async () => {
const res = await axios.get('/api/users/me')
setData(res.data.data._id)
}
return(
<div>
<h1>Profile</h1>
<h2>{data==="nothing" ? "Nothing": {data}
<button onClick={getUserDetails}>Details</button>
</div>
)
}
Backend
// src/app/api/users/me/route.ts
connect()
export async function GET(request:NextRequest){
try {
// Extract user ID from the authentication token
const userId = await getDataFromToken(request);
// Find the user in the database based on the user ID
const user = await User.findOne({_id: userId}).
select("-password");
return NextResponse.json({
message: "User found",
data: user
})
} catch (error: any) {
return NextResponse.json({error: error.message}, {status: 400})
}
}
- Uses the extracted user ID to query the database and retrieve the user information.
- The
.select("-password")
part excludes the user's password from the returned data. This is a common practice to avoid exposing sensitive information.
User verification — sending confirmation email
Here we are using Mailtrap to confirm the user email ID. Make a free account in Mailtrap and get the credentials from the path below:
email testing > inboxes > my inbox > integration > nodemailer
// src/app/helpers/mailer.ts
import nodemailer from "nodemailer"
import User from "@/models/userModel"
import bcryptjs from "bcryptjs"
export const sendEmail = async({email, emailType, userId}:any) =>{
try {
// Create a hash token based on the user's ID
const hashedToken = await bcryptjs.hash(userId.toString(), 10)
// Update the user document in the database with the generated token and expiry time
if(emailType === "VERIFY") {
await User.findByIdAndUpdate(userId,
{
verifyToken: hashedToken,
verifyTokenExpiry: Date.now() + 3600000
},
)
} else if(emailType === "RESET") {
await User.findByIdAndUpdate(userId,
{
forgotPasswordToken: hashedToken,
forgotPasswordTokenExpiry: Date.now() + 3600000
},
)
}
// Create a nodemailer transport
var transport = nodemailer.createTransport({
host: "sandbox.smtp.mailtrap.io",
port: 2525,
auth: {
user: <user_id>,
pass: <password>
}
});
// Compose email options
const mailOptions = {
from: '<your email id>',
to: email,
subject: emailType === "VERIFY" ? "Verify your email" : "Reset your password",
html: `<p>Click <a href="${process.env.domain}/verifyemail?token=${hashedToken}">here</a> to
${emailType === "VERIFY" ? "Verify your email" : "Reset your password"}</p>`
}
// Send the email
const mailresponse = await transport.sendMail(mailOptions);
return mailresponse
} catch (error: any) {
throw new Error(error.message);
}
}
This function combines token generation, database update, nodemailer configuration, and email sending to facilitate the email verification and password reset processes in your application.
// src/app/api/users/verifyemail/route.ts
import { connect } from "@/dbConfig/dbConfig";
import { NextRequest, NextResponse } from "next/server";
import User from "@/models/userModel"
connect()
export async function POST(request: NextRequest) {
try {
// It extracts the token property from the JSON body of the incoming request.
const reqBody = await request.json()
const {token} = reqBody
const user = await User.findOne({verifyToken: token, verifyTokenExpiry: {$gt: Date.now()}});
if(!user){
return NextResponse.json({error: "Invalid token"}, {status: 400})
}
// Update user properties and save the changes
user.usVerified = true;
user.verifyToken = undefined;
user.verifyTokenExpiry = undefined;
await user.save();
return NextResponse.json({
message: "Email Verified successfully",
success: true
})
} catch (error: any) {
return NextResponse.json({error: error.message},
{status: 500})
}
}
This code is part of the email verification process where a user’s email is marked as verified in the database after successfully validating the provided verification token. If the token is invalid or expired, it returns an error response.
// src/app/api/users/signup/route.ts
//send verification email
await sendEmail({email, emailType: "VERIFY",
userId: savedUser._id})
Frontend
// src/app/verifyemail/page.ts
"use client";
export default function VerifyEmailPage() {
const [token, setToken] = useState("");
const [verified, setVerified] = useState(false);
const [error, setError] = useState(false);
const verifyUserEmail = async () => {
try {
await axios.post("/api/users/verifyemail", { token });
setVerified(true);
} catch (error: any) {
setError(true);
console.log(error.response.data);
}
};
useEffect(() => {
const urlToken = window.location.search.split("=")[1];
setToken(urlToken || "");
}, []);
useEffect(() => {
if (token.length > 0) {
verifyUserEmail();
}
}, [token]);
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<h1 className="text-4xl">Verify Email</h1>
<h2 className="p-2 bg-orange-500 text-black">
{token ? `${token}` : "no token"}
</h2>
{verified && (
<div>
<h2 className="text-2xl">Email Verified</h2>
<Link href="/login">Login</Link>
</div>
)}
{error && (
<div>
<h2 className="text-2xl bg-red-500">Error</h2>
</div>
)}
</div>
);
}
Backend
// src/app/api/users/verifyemail/route.ts
connect()
export async function POST(request: NextRequest) {
try {
const reqBody = await request.json()
const {token} = reqBody
const user = await User.findOne({verifyToken: token, verifyTokenExpiry: {$gt: Date.now()}});
if(!user){
return NextResponse.json({error: "Invalid token"}, {status: 400})
}
user.isVerified = true;
user.verifyToken = undefined;
user.verifyTokenExpiry = undefined;
await user.save();
return NextResponse.json({
message: "Email Verified successfully",
success: true
})
} catch (error: any) {
return NextResponse.json({error: error.message},
{status: 500})
}
}
Congratulations on completing the journey into building a robust user authentication system with Next.js and MongoDB! Throughout this article, we’ve covered the fundamentals of Next.js, set up a basic Next.js project, and delved into the intricacies of user authentication.
Thank you for joining us on this exploration of Next.js and MongoDB for user authentication. Happy coding, and best of luck with your future projects!