Complete Authentication System with Next.js 15, NextAuth v5, Prisma & MongoDB
Building a secure authentication system is crucial for modern web applications. In this comprehensive guide, we'll create a production-ready authentication system using the latest technologies: Next.js 15, NextAuth v5, Prisma ORM, and MongoDB Atlas.
What You'll Build
By the end of this tutorial, you'll have:
✅ Multi-provider authentication (Google, GitHub, Email/Password)
✅ Secure JWT-based sessions
✅ Protected routes with middleware
✅ User registration and login forms
✅ MongoDB integration with Prisma ORM
✅ Production-ready configuration
Prerequisites
Before starting, ensure you have:
Node.js v20 or later installed
A MongoDB Atlas account (free tier works)
Google Developer Console access
GitHub account for OAuth setup
Step 1: Project Setup
Initialize Your Next.js Project
npx create-next-app@latest my-auth-app --typescript --tailwind --eslint --app
cd my-auth-app
Install Required Dependencies
# Core authentication packages
npm install next-auth@beta @prisma/client @auth/prisma-adapter
# Development dependencies
npm install -D prisma
# Additional packages for forms and validation
npm install react-hook-form @hookform/resolvers zod bcryptjs
Initialize Prisma with MongoDB
npx prisma init --datasource-provider mongodb
This creates a prisma
directory with schema.prisma
and a .env
file.
Step 2: Database Configuration
Set Up MongoDB Atlas
Create a MongoDB Atlas Account: Visit MongoDB Atlas and sign up
Create a New Project: Name it something like "nextjs-auth-project"
Build a Database: Choose the free M0 tier
Configure Network Access:
Go to Network Access → Add IP Address
Choose "Allow Access from Anywhere" for development
Create Database User:
Go to Database Access → Add New Database User
Choose password authentication
Save the username and password
Configure Environment Variables
Update your .env
file:
# Database
DATABASE_URL="mongodb+srv://username:[email protected]/nextjs-auth?retryWrites=true&w=majority"
# NextAuth
AUTH_SECRET="your-super-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
# OAuth Providers (we'll fill these later)
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
Generate AUTH_SECRET:
openssl rand -base64 32
Define Prisma Schema
Update prisma/schema.prisma
:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
role UserRole @default(USER)
accounts Account[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
type String
provider String
providerAccountId String
refresh_token String? @db.String
access_token String? @db.String
expires_at Int?
token_type String?
scope String?
id_token String? @db.String
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
enum UserRole {
USER
ADMIN
}
Create Database Client
Create lib/db.ts
:
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Generate and Push Schema
npx prisma generate
npx prisma db push
Step 3: OAuth Provider Setup
Google OAuth Setup
Go to Google Cloud Console
Create a new project or select existing
Enable Google+ API
Go to "Credentials" → "Create Credentials" → "OAuth Client ID"
Configure OAuth consent screen first
Create OAuth 2.0 Client ID:
Application type: Web application
Authorized origins:
http://localhost:3000
Authorized redirect URIs:
http://localhost:3000/api/auth/callback/google
Copy Client ID and Client Secret to your
.env
file
GitHub OAuth Setup
Click "New OAuth App"
Fill in details:
Application name: "My Auth App"
Homepage URL:
http://localhost:3000
Authorization callback URL:
http://localhost:3000/api/auth/callback/github
Copy Client ID and Client Secret to your
.env
file
Step 4: NextAuth Configuration
Create Auth Configuration
Create auth.config.ts
:
import type { NextAuthConfig } from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
import { prisma } from './lib/db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const CredentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
})
export default {
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const parsedCredentials = CredentialsSchema.safeParse(credentials)
if (!parsedCredentials.success) return null
const { email, password } = parsedCredentials.data
const user = await prisma.user.findUnique({
where: { email }
})
if (!user || !user.password) return null
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
}
},
}),
],
} satisfies NextAuthConfig
Create Main Auth Configuration
Create auth.ts
:
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './lib/db'
import authConfig from './auth.config'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
...authConfig,
callbacks: {
async session({ session, token }) {
if (token.sub && session.user) {
session.user.id = token.sub
}
return session
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id
}
return token
},
},
events: {
async linkAccount({ user }) {
await prisma.user.update({
where: { id: user.id },
data: { emailVerified: new Date() },
})
},
},
})
Create API Route
Create app/api/auth/[...nextauth]/route.ts
:
import { handlers } from '@/auth'
export const { GET, POST } = handlers
Step 5: Server Actions for Authentication
Create lib/actions.ts
:
'use server'
import { prisma } from '@/lib/db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const RegisterSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
name: z.string().min(1, 'Name is required'),
})
export async function register(formData: FormData) {
try {
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
name: formData.get('name') as string,
}
const validated = RegisterSchema.parse(data)
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: validated.email }
})
if (existingUser) {
return { success: false, error: 'User already exists' }
}
const hashedPassword = await bcrypt.hash(validated.password, 10)
const user = await prisma.user.create({
data: {
email: validated.email,
name: validated.name,
password: hashedPassword,
},
})
return { success: true, message: 'User created successfully' }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message }
}
return { success: false, error: 'Something went wrong' }
}
}
Step 6: Create Authentication UI
Session Provider Setup
Update app/layout.tsx
:
import { SessionProvider } from 'next-auth/react'
import { auth } from '@/auth'
import './globals.css'
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
return (
<html lang="en">
<body>
<SessionProvider session={session}>
{children}
</SessionProvider>
</body>
</html>
)
}
Create Login Page
Create app/login/page.tsx
:
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleCredentialsLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.error) {
setError('Invalid credentials')
} else {
router.push('/dashboard')
}
} catch (error) {
setError('Something went wrong')
} finally {
setLoading(false)
}
}
const handleOAuthLogin = async (provider: 'google' | 'github') => {
setLoading(true)
await signIn(provider, { callbackUrl: '/dashboard' })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-md">
<div>
<h2 className="text-3xl font-bold text-center text-gray-900">
Sign in to your account
</h2>
</div>
<form className="space-y-6" ={handleCredentialsLogin}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
value={email}
={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
value={password}
={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<button
={() => handleOAuthLogin('google')}
disabled={loading}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span className="ml-2">Google</span>
</button>
<button
={() => handleOAuthLogin('github')}
disabled={loading}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
<span className="ml-2">GitHub</span>
</button>
</div>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
Sign up
</Link>
</p>
</div>
</div>
</div>
)
}
Create Registration Page
Create app/register/page.tsx
:
'use client'
import { useState } from 'react'
import { register } from '@/lib/actions'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
export default function RegisterPage() {
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
setError('')
setSuccess('')
const formData = new FormData(e.currentTarget)
const result = await register(formData)
if (result.success) {
setSuccess('Account created successfully! Redirecting to login...')
setTimeout(() => router.push('/login'), 2000)
} else {
setError(result.error || 'Something went wrong')
}
setLoading(false)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-md">
<div>
<h2 className="text-3xl font-bold text-center text-gray-900">
Create your account
</h2>
</div>
<form className="space-y-6" ={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
{success}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={6}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">Must be at least 6 characters</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}
Create Dashboard Page
Create app/dashboard/page.tsx
:
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { signOut } from '@/auth'
import Image from 'next/image'
export default async function Dashboard() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<form
action={async () => {
'use server'
await signOut({ redirectTo: '/login' })
}}
>
<button
type="submit"
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
>
Sign Out
</button>
</form>
</div>
<div className="bg-gray-50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">User Information</h2>
<div className="space-y-3">
{session.user.image && (
<div className="flex items-center space-x-3">
<Image
src={session.user.image}
alt="Profile"
width={64}
height={64}
className="rounded-full"
/>
</div>
)}
<div>
<span className="font-medium">Name:</span> {session.user.name || 'Not provided'}
</div>
<div>
<span className="font-medium">Email:</span> {session.user.email || 'Not provided'}
</div>
<div>
<span className="font-medium">User ID:</span> {session.user.id}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
Step 7: Route Protection
Create Middleware
Create middleware.ts
in the root directory:
export { auth as middleware } from '@/auth'
export const config = {
matcher: ['/dashboard/:path*', '/profile/:path*', '/admin/:path*'],
}
Update Next.js Config
Update next.config.js
:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
],
},
}
export default nextConfig
Step 8: Testing Your Application
Start the Development Server
npm run dev
Test the Authentication Flow
Create an account with email and password
Navigate to
http://localhost:3000/login
Test all login methods:
Email/Password credentials
Google OAuth
GitHub OAuth
Verify protected routes work correctly
Test sign out functionality
Common Issues and Solutions
Database Connection Issues:
Verify your MongoDB Atlas connection string
Check if your IP is whitelisted
Ensure database user has proper permissions
OAuth Provider Issues:
Double-check redirect URIs match exactly
Verify client IDs and secrets are correct
Ensure OAuth consent screens are configured
Build Issues:
Run
npx prisma generate
after schema changesClear
.next
folder and restart dev serverCheck all environment variables are set
Production Deployment
Environment Variables for Production
DATABASE_URL="your-production-mongodb-url"
AUTH_SECRET="your-production-secret"
NEXTAUTH_URL="https://your-domain.com"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"
Pre-deployment Checklist
[ ] Update OAuth redirect URIs for production domain
[ ] Set production environment variables
[ ] Run
npx prisma generate
andnpx prisma db push
[ ] Test all authentication flows in production
[ ] Configure proper CORS settings
[ ] Set up proper error monitoring
Security Best Practices
Never commit
.env
files to version controlUse strong, unique secrets for AUTH_SECRET
Implement rate limiting for authentication endpoints
Regular security audits of dependencies
Monitor authentication logs for suspicious activity
Use HTTPS in production always
Implement proper session management
Conclusion
You now have a production-ready authentication system with:
✅ Multiple authentication providers (Google, GitHub, Credentials)
✅ Secure password hashing and validation
✅ JWT-based session management
✅ Protected routes with middleware
✅ MongoDB database integration
✅ TypeScript support for type safety
✅ Modern UI with Tailwind CSS
This setup provides a solid foundation for any Next.js application requiring secure user authentication. The modular architecture makes it easy to add additional providers or customize the authentication flow as needed.
Next Steps:
Add email verification
Implement password reset functionality
Add user roles and permissions
Set up proper error handling and logging
Consider adding two-factor authentication
Happy coding! 🚀