Build a production-ready authentication system! Implement NextAuth.js for authentication, middleware for route protection, role-based access control (RBAC) for permissions, JWT tokens with refresh, session management, user registration, and a complete admin dashboard. This project demonstrates building secure, enterprise-grade authentication!
Project Overview
Features We'll Build
- User Authentication: Login, logout, session management
- User Registration: Sign up with validation
- Protected Routes: Middleware-based route protection
- Role-Based Access: Admin, user, and guest roles
- JWT Tokens: Secure token-based authentication
- Session Management: Persistent sessions with refresh
- User Dashboard: Personalized user area
- Admin Dashboard: User management and analytics
- Profile Management: Update user information
- Security Best Practices: Password hashing, CSRF protection
Technologies Used
- NextAuth.js v5 (Auth.js) for authentication
- Prisma with PostgreSQL for database
- bcrypt for password hashing
- JWT for token-based auth
- Middleware for route protection
- Server Actions for mutations
Project Setup
Install Dependencies
# Create Next.js project
npx create-next-app@latest dashboard-auth
cd dashboard-auth
# Install NextAuth.js
npm install next-auth@beta
# Install Prisma
npm install @prisma/client
npm install -D prisma
# Install bcrypt for password hashing
npm install bcrypt
npm install -D @types/bcrypt
# Install jose for JWT
npm install jose
# Initialize Prisma
npx prisma init
# ✅ All dependencies installed
# ✅ Database configuredDatabase Schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
role Role @default(USER)
emailVerified DateTime?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// Run migrations:
// npx prisma migrate dev --name init
// npx prisma generate
// ✅ Complete user schema
// ✅ Role-based access control
// ✅ Session management
// ✅ OAuth support readyEnvironment Variables
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/dashboard_auth"
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-generate-with-openssl-rand-base64-32"
# Optional: OAuth providers
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GITHUB_ID=""
GITHUB_SECRET=""
# ✅ Configure database
# ✅ Set authentication secrets
# ✅ Optional OAuth providersAuthentication Project Structure
Complete file organization for authenticated dashboard
Select a file or folder to see details
NextAuth.js Configuration
Auth Configuration
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from './db';
import bcrypt from 'bcrypt';
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error('Invalid credentials');
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.password) {
throw new Error('Invalid credentials');
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password
);
if (!isPasswordValid) {
throw new Error('Invalid credentials');
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
session: {
strategy: 'jwt',
},
pages: {
signIn: '/login',
signOut: '/login',
error: '/login',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
};
// ✅ Credentials authentication
// ✅ Password verification
// ✅ JWT strategy
// ✅ Custom callbacks
// ✅ Role in sessionNextAuth API Route
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
// ✅ NextAuth.js route handler
// ✅ Handles all auth endpoints
// ✅ /api/auth/signin
// ✅ /api/auth/signout
// ✅ /api/auth/sessionSession Type Extension
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession['user'];
}
interface User {
role: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: string;
}
}
// ✅ Type-safe session
// ✅ Custom user properties
// ✅ JWT token typesUser Registration
Registration API Route
import { NextResponse } from 'next/server';
import bcrypt from 'bcrypt';
import { prisma } from '@/lib/db';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2, 'Name must be at least 2 characters'),
});
export async function POST(request: Request) {
try {
const body = await request.json();
// Validate input
const validatedData = registerSchema.parse(body);
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email: validatedData.email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(validatedData.password, 10);
// Create user
const user = await prisma.user.create({
data: {
email: validatedData.email,
password: hashedPassword,
name: validatedData.name,
role: 'USER',
},
select: {
id: true,
email: true,
name: true,
role: true,
},
});
return NextResponse.json(
{ user, message: 'User created successfully' },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// ✅ Input validation with Zod
// ✅ Password hashing
// ✅ Duplicate check
// ✅ Error handling
// ✅ Secure responseRegistration Form
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function RegisterPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
name: formData.get('name') as string,
};
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Registration failed');
}
// Redirect to login
router.push('/login?registered=true');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6">
Create Account
</h1>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
At least 8 characters
</p>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-semibold"
>
{isLoading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
<p className="text-center text-sm text-gray-600 mt-6">
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Sign in
</Link>
</p>
</div>
</div>
</div>
);
}
// ✅ Form validation
// ✅ Error handling
// ✅ Loading states
// ✅ Password requirements
// ✅ Redirect after successLogin Implementation
Login Form
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const registered = searchParams.get('registered');
const callbackUrl = searchParams.get('callbackUrl') || '/dashboard';
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
try {
const result = await signIn('credentials', {
email: formData.get('email') as string,
password: formData.get('password') as string,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
return;
}
router.push(callbackUrl);
router.refresh();
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6">
Sign In
</h1>
{registered && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 text-green-700 rounded">
Account created successfully! Please sign in.
</div>
)}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center justify-between text-sm">
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span>Remember me</span>
</label>
<Link href="/forgot-password" className="text-blue-600 hover:underline">
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-semibold"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p className="text-center text-sm text-gray-600 mt-6">
Don't have an account?{' '}
<Link href="/register" className="text-blue-600 hover:underline">
Sign up
</Link>
</p>
</div>
</div>
</div>
);
}
// ✅ NextAuth.js signIn
// ✅ Error handling
// ✅ Callback URL support
// ✅ Loading states
// ✅ Success messageRoute Protection with Middleware
Authentication Middleware
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
// Define protected routes and their required roles
const protectedRoutes = {
'/dashboard': ['USER', 'ADMIN'],
'/profile': ['USER', 'ADMIN'],
'/admin': ['ADMIN'],
};
const authRoutes = ['/login', '/register'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Get token (session)
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
// Check if route is protected
const isProtectedRoute = Object.keys(protectedRoutes).some((route) =>
pathname.startsWith(route)
);
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));
// Redirect to login if accessing protected route without authentication
if (isProtectedRoute && !token) {
const url = new URL('/login', request.url);
url.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(url);
}
// Redirect authenticated users away from auth pages
if (isAuthRoute && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Check role-based access
if (isProtectedRoute && token) {
const route = Object.keys(protectedRoutes).find((route) =>
pathname.startsWith(route)
);
if (route) {
const allowedRoles = protectedRoutes[route as keyof typeof protectedRoutes];
const userRole = token.role as string;
if (!allowedRoles.includes(userRole)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
}
}
return NextResponse.next();
}
export const config = {
matcher: [
'/dashboard/:path*',
'/profile/:path*',
'/admin/:path*',
'/login',
'/register',
],
};
// ✅ Route protection
// ✅ Role-based access control
// ✅ Callback URL preservation
// ✅ Auth route redirects
// ✅ Unauthorized handlingDashboard Implementation
User Dashboard
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { SignOutButton } from '@/components/SignOutButton';
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/login');
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-gray-600">Welcome back, {session.user.name}!</p>
</div>
<SignOutButton />
</div>
{/* User Info Card */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">Your Information</h2>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-gray-600">Name</dt>
<dd className="font-semibold">{session.user.name}</dd>
</div>
<div>
<dt className="text-sm text-gray-600">Email</dt>
<dd className="font-semibold">{session.user.email}</dd>
</div>
<div>
<dt className="text-sm text-gray-600">Role</dt>
<dd>
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm font-medium">
{session.user.role}
</span>
</dd>
</div>
<div>
<dt className="text-sm text-gray-600">User ID</dt>
<dd className="font-mono text-sm">{session.user.id}</dd>
</div>
</dl>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
href="/profile"
className="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition-shadow"
>
<h3 className="font-semibold mb-2">Edit Profile</h3>
<p className="text-sm text-gray-600">Update your personal information</p>
</a>
href="/settings"
className="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition-shadow"
>
<h3 className="font-semibold mb-2">Settings</h3>
<p className="text-sm text-gray-600">Manage your account settings</p>
</a>
{session.user.role === 'ADMIN' && (
href="/admin"
className="block p-6 bg-purple-50 border-2 border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
<h3 className="font-semibold mb-2 text-purple-900">Admin Panel</h3>
<p className="text-sm text-purple-700">Manage users and settings</p>
</a>
)}
</div>
</div>
</div>
);
}
// ✅ Server-side session check
// ✅ User information display
// ✅ Role-based content
// ✅ Quick actions
// ✅ Protected routeAdmin Dashboard
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/db';
export default async function AdminPage() {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
redirect('/dashboard');
}
// Fetch users
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
// Calculate stats
const totalUsers = users.length;
const adminCount = users.filter((u) => u.role === 'ADMIN').length;
const userCount = users.filter((u) => u.role === 'USER').length;
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Admin Dashboard</h1>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600 mb-1">Total Users</p>
<p className="text-3xl font-bold">{totalUsers}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600 mb-1">Administrators</p>
<p className="text-3xl font-bold">{adminCount}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600 mb-1">Regular Users</p>
<p className="text-3xl font-bold">{userCount}</p>
</div>
</div>
{/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b">
<h2 className="text-xl font-semibold">All Users</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Joined
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900">{user.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
user.role === 'ADMIN'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
// ✅ Admin-only access
// ✅ User statistics
// ✅ Users table
// ✅ Role badges
// ✅ Data from databaseAuthentication Components
Sign Out Button
'use client';
import { signOut } from 'next-auth/react';
export function SignOutButton() {
return (
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Sign Out
</button>
);
}
// ✅ NextAuth signOut
// ✅ Callback URL
// ✅ Client componentAuth Status Component
'use client';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
export function AuthStatus() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <div>Loading...</div>;
}
if (session) {
return (
<div className="flex items-center gap-4">
<span className="text-sm">
Hello, {session.user.name}
</span>
<Link
href="/dashboard"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Dashboard
</Link>
</div>
);
}
return (
<div className="flex items-center gap-4">
<Link
href="/login"
className="px-4 py-2 text-gray-700 hover:text-gray-900"
>
Sign In
</Link>
<Link
href="/register"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Sign Up
</Link>
</div>
);
}
// ✅ Session status
// ✅ Conditional rendering
// ✅ Navigation linksProtected Component Example
'use client';
import { useSession } from 'next-auth/react';
export function AdminOnly({ children }: { children: React.ReactNode }) {
const { data: session } = useSession();
if (session?.user.role !== 'ADMIN') {
return null;
}
return <>{children}</>;
}
// Usage:
// <AdminOnly>
// <button>Admin Action</button>
// </AdminOnly>
// ✅ Role-based rendering
// ✅ Reusable wrapper
// ✅ Client-side checkKey Takeaways
- NextAuth.js - complete authentication solution
- Middleware - protect routes before page access
- Role-based access - control permissions by user role
- JWT tokens - secure, stateless authentication
- Password hashing - bcrypt for security
- Session management - persistent user sessions
- Protected routes - server-side authentication checks
- Type safety - TypeScript for session and user types
What's Next?
You've built a complete authentication system! Next, we'll build Project 4: Full-Stack Application—bringing everything together in a comprehensive capstone project that integrates all Next.js features: authentication, database, API routes, file uploads, real-time updates, and deployment. The ultimate Next.js project!
💡 Project Enhancement Ideas
Extend your auth system: Add email verification, password reset functionality, two-factor authentication (2FA), OAuth providers (Google, GitHub), account deletion, audit logs, session management, rate limiting, or integrate with services like Auth0 or Clerk!