Complete Authentication System with Next ...

Complete Authentication System with Next.js 15, NextAuth v5, Prisma & MongoDB

Jun 26, 2025

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

  1. Create a MongoDB Atlas Account: Visit MongoDB Atlas and sign up

  2. Create a New Project: Name it something like "nextjs-auth-project"

  3. Build a Database: Choose the free M0 tier

  4. Configure Network Access:

    • Go to Network Access → Add IP Address

    • Choose "Allow Access from Anywhere" for development

  5. 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

  1. Go to Google Cloud Console

  2. Create a new project or select existing

  3. Enable Google+ API

  4. Go to "Credentials" → "Create Credentials" → "OAuth Client ID"

  5. Configure OAuth consent screen first

  6. Create OAuth 2.0 Client ID:

  7. Copy Client ID and Client Secret to your .env file

GitHub OAuth Setup

  1. Go to GitHub Developer Settings

  2. Click "New OAuth App"

  3. Fill in details:

  4. 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

  1. Visit http://localhost:3000/register

  2. Create an account with email and password

  3. Navigate to http://localhost:3000/login

  4. Test all login methods:

    • Email/Password credentials

    • Google OAuth

    • GitHub OAuth

  5. Verify protected routes work correctly

  6. 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 changes

  • Clear .next folder and restart dev server

  • Check 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 and npx prisma db push

  • [ ] Test all authentication flows in production

  • [ ] Configure proper CORS settings

  • [ ] Set up proper error monitoring

Security Best Practices

  1. Never commit .env files to version control

  2. Use strong, unique secrets for AUTH_SECRET

  3. Implement rate limiting for authentication endpoints

  4. Regular security audits of dependencies

  5. Monitor authentication logs for suspicious activity

  6. Use HTTPS in production always

  7. 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! 🚀

Enjoy this post?

Buy Noor Mohammad a coffee

More from Noor Mohammad

PrivacyTermsReport