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>
);
}
This form submits credentials using signIn from NextAuth. If valid, the user is redirected to the dashboard.
Protect the Dashboard Route (Client-Side Check)
/app/dashboard/page.jsx
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export default function DashboardPage() {
const { data: session, status } = useSession();
const router = useRouter();
if (status === 'loading') {
return <p>Loading...</p>;
}
if (!session) {
router.push('/login');
return null;
}
return (
<div>
<h1>Welcome {session.user.name}</h1>
<p>Your role: {session.user.role}</p>
</div>
);
}
This protects the dashboard from unauthorized access by checking the session on the client side.
Role-Based Access to Admin Page
/app/admin/page.jsx
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export default function AdminPage() {
const { data: session, status } = useSession();
const router = useRouter();
if (status === 'loading') return <p>Loading...</p>;
if (!session) {
router.push('/login');
return null;
}
if (session.user.role !== 'admin') {
return <p>Access Denied</p>;
}
return (
<div>
<h1>Admin Dashboard</h1>
<p>You have admin rights.</p>
</div>
);
}
Even if logged in, only users with the role 'admin' can access this page. Others will see “Access Denied”.
Server-Side Protection Using Middleware
/middleware.js
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
export async function middleware(req) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
const isProtected = req.nextUrl.pathname.startsWith('/dashboard') || req.nextUrl.pathname.startsWith('/admin');
if (isProtected && !token) {
return NextResponse.redirect(new URL('/login', req.url));
}
if (req.nextUrl.pathname.startsWith('/admin') && token?.role !== 'admin') {
return new NextResponse('Forbidden', { status: 403 });
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*']
};
This middleware runs before rendering the page. It ensures that only authenticated users access protected routes. Admin routes are restricted based on role.
Final Thoughts & Conclusion
In today’s modern web applications, authentication (knowing who the user is) and authorization (what that user can access) are foundational to building secure and user-friendly platforms.
With the advent of Next.js 14 (App Router), the integration of NextAuth.js for authentication and JWT for stateless session handling makes it much easier to build robust systems even with complex requirements like role-based access control.
Here’s what we’ve achieved.
- Secure Login System: With hashed passwords using bcrypt and verified credentials using CredentialsProvider.
- MongoDB Integration: Using Mongoose to define user schemas and persist data.
- JWT-based Sessions: For lightweight and scalable authentication without server-side session storage.
- Role-based Access: Easily manage what each user type can access (e.g., admin vs. regular user).
- Server-side Middleware: An Extra layer of protection for sensitive routes using Next.js middleware.js.
Why is this approach Production-Ready?
- Scalable: Stateless sessions with JWT reduce server memory load.
- Secure: Passwords are hashed; protected routes are enforced on both the client and server sides.
- Extensible: Easily add features like email/password signup, OAuth providers (Google, GitHub), or admin dashboards.
- Next.js Native: Fully utilizes Next.js 14's App Router, layouts, and server components.
In today's web landscape, authentication is more than just logging in; it's about trust, security, and seamless access control. This article walks you through implementing secure authentication and role-based authorization in a Next.js 14 app using NextAuth.js, MongoDB, and JWT. Learn how to build protected routes, manage sessions, and enforce admin/user roles in a scalable, modern way using the App Router and server-side middleware ideal for both personal and production-ready projects.