Authentication and authorization are two pillars of secure web applications. In 2025, with the evolution of frameworks like Next.js 14, implementing secure login and role-based access is easier and more powerful than ever before, especially using NextAuth.js and JWT.
In this article, you'll learn how to implement authentication and authorization in a Next.js app (App Router) using.
- MongoDB (via Mongoose)
- NextAuth.js
- JWT for session management
- Role-based access control
Tech Stack
- Frontend: Next.js 14 (App Router)
- Backend: API Routes in Next.js
- Database: MongoDB (MongoDB Atlas)
- ORM: Mongoose
- Auth Provider: NextAuth.js
- Session: JWT (stateless)
Project Structure
/auth-nextjs-app
│
├── /app
│ └── /dashboard
│ └── /admin
│ └── /login
├── /lib
│ └── mongodb.js
├── /models
│ └── User.js
├── /pages/api/auth/[...nextauth].js
├── /middleware.js
├── /utils
│ └── auth.js
Set up MongoDB and Mongoose
npm install mongoose next-auth bcrypt
lib/mongodb.js
This utility ensures we don’t reconnect repeatedly to MongoDB. It creates a single connection across your app lifecycle.
import mongoose from 'mongoose';
const connectDB = async () => {
if (mongoose.connections[0].readyState) return;
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
};
export default connectDB;
Create a User Model
/models/User.js
import mongoose from 'mongoose';
const UserSchema = new mongoose.Schema({
name: String,
email: { type: String, unique: true },
password: String,
role: { type: String, default: 'user' } // roles: user, admin
});
export default mongoose.models.User || mongoose.model('User', UserSchema);
This schema defines your user object. The role field enables role-based access control, distinguishing between admin and user.
Configure NextAuth with Credentials and JWT
/pages/api/auth/[...nextauth].js
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import User from '../../../models/User';
import connectDB from '../../../lib/mongodb';
import bcrypt from 'bcrypt';
export const authOptions = {
providers: [
CredentialsProvider({
async authorize(credentials) {
await connectDB();
const user = await User.findOne({ email: credentials.email });
if (!user) throw new Error("User not found");
const isValid = await bcrypt.compare(credentials.password, user.password);
if (!isValid) throw new Error("Invalid password");
return { id: user._id, name: user.name, email: user.email, role: user.role };
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) token.role = user.role;
return token;
},
async session({ session, token }) {
session.user.role = token.role;
return session;
}
},
session: {
strategy: 'jwt'
},
secret: process.env.NEXTAUTH_SECRET
};
export default NextAuth(authOptions);
We're using CredentialsProvider to allow email/password login. On login, we check the user’s credentials and embed their role into the JWT token. This token is later used to authorize protected routes.
Create the Login Page (Client Side)
/app/login/page.jsx
'use client';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const handleLogin = async (e) => {
e.preventDefault();
const res = await signIn('credentials', {
email,
password,
redirect: false,
});
if (res.ok) {
router.push('/dashboard');
} else {
alert('Login failed!');
}
};
return (
<form onSubmit={handleLogin} className="max-w-sm mx-auto">
<h2 className="text-xl font-bold mb-4">Login</h2>
<input
type="email"
placeholder="Email"
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Login</button>
</form>
);
}
Join the conversation! Your thoughts help the community grow.