Next.js Authentication With Next-Auth V5 || Credential Provider
Real-world Next-Auth v5 Credentials provider in Next.js App Router — email/password login, authorize function, bcrypt password matching, mock user database, Server Actions, and client-side error UX.
Introduction
This guide covers only Next.js authentication with Next-Auth v5 Credentials provider — email and password login without OAuth. You will build a real-world flow: hashed passwords, a mock user store, server-side validation, and client-side error feedback.
Next-Auth v5 (next-auth@5) uses a Credentials provider with an authorize function where you
verify email/password against your database (mocked here for learning, swappable for Prisma or Postgres in production).
Next-Auth V5 Credential Login
│
┌───────┬───────┼───────┬───────────┐
│ │ │ │ │
UI Config Mock DB Server Client
Impl (auth.ts) Actions LogicWhat you will build:
| Branch | What it covers |
|---|---|
| 1. UI implementation | Credential login form, email/password inputs, dynamic homepage, social login separation |
| 2. Configuration | Credentials provider, JWT strategy, authorize function |
| 3. Mock database | Users data file, getUserByEmail helper, dummy user array |
| 4. Server Actions | doCredentialLogin with signIn, FormData, redirect: false |
| 5. Client-side logic | handleFormSubmit, error state, useRouter navigation |
Quick index
1. Install & project structure
npm install next-auth@5 bcryptjs
npm install -D @types/bcryptjs
npx auth secretbcryptjs hashes and compares passwords inside authorize — never store plain-text passwords, even in a mock database.
Folder structure
├── app/
│ ├── api/auth/[...nextauth]/route.ts
│ ├── login/page.tsx
│ ├── dashboard/page.tsx
│ └── page.tsx # Dynamic view by session
├── components/
│ ├── CredentialLoginForm.tsx # Email + password (client)
│ ├── SocialLoginSection.tsx # Optional — kept separate from credentials
│ └── LogoutButton.tsx
├── actions/
│ └── auth.ts # doCredentialLogin, doLogout
├── lib/
│ └── users.ts # Mock DB + getUserByEmail
├── auth.ts # Credentials provider config
└── scripts/
└── hash-password.mjs # Generate bcrypt hashes for seed data2. Mock database
In production you would query Prisma, Drizzle, or raw SQL. For development and demos, a typed users file plus a getUserByEmail helper is enough to exercise the full login flow.
Users data file
// lib/users.ts
export type UserRecord = {
id: string
name: string
email: string
password: string // bcrypt hash — never plain text
}
// Dummy user array — replace with DB queries in production
const users: UserRecord[] = [
{
id: '1',
name: 'Jane Doe',
email: 'jane@example.com',
// hash of "password123" — generate with: node scripts/hash-password.mjs password123
password: '$2b$10$xJPiSexx0JX..GbPnJOMm.JT6QjcPdH4XkIvNDKu6l5eeEzG7XXti',
},
{
id: '2',
name: 'John Smith',
email: 'john@example.com',
password: '$2b$10$xJPiSexx0JX..GbPnJOMm.JT6QjcPdH4XkIvNDKu6l5eeEzG7XXti',
},
]
export async function getUserByEmail(email: string): Promise<UserRecord | null> {
const normalized = email.trim().toLowerCase()
const user = users.find((u) => u.email.toLowerCase() === normalized)
return user ?? null
}Generate real bcrypt hashes
// scripts/hash-password.mjs
import bcrypt from 'bcryptjs'
const password = process.argv[2] ?? 'password123'
const hash = await bcrypt.hash(password, 10)
console.log(hash)node scripts/hash-password.mjs password123
# Paste output into lib/users.ts3. Core configuration — auth.ts
The Credentials provider wires email/password fields to your authorize function. Next-Auth calls authorize on every login attempt — that is where validation, lookup, and password matching happen.
CredentialsProvider import & JWT strategy
// auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { getUserByEmail } from '@/lib/users'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// 1. Credentials argument — validate shape
if (!credentials?.email || !credentials?.password) {
throw new Error('Email and password are required.')
}
const email = String(credentials.email)
const password = String(credentials.password)
// 2. User validation logic — lookup by email
const user = await getUserByEmail(email)
if (!user) {
return null // invalid credentials — same response as wrong password
}
// 3. Password matching — compare against bcrypt hash
const passwordsMatch = await bcrypt.compare(password, user.password)
if (!passwordsMatch) {
return null
}
// 4. Return user object — becomes JWT session payload
return {
id: user.id,
name: user.name,
email: user.email,
}
},
}),
],
pages: {
signIn: '/login',
},
session: {
strategy: 'jwt',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
async session({ session, token }) {
if (session.user && token.sub) {
session.user.id = token.sub
}
return session
},
},
})Authorize function breakdown
| Step | Responsibility | On failure |
|---|---|---|
| Credentials argument | Ensure email and password exist | throw new Error(...) for missing fields |
| User validation logic | getUserByEmail(email) | return null |
| Password matching | bcrypt.compare(plain, hash) | return null |
| Success | Return { id, name, email } | — |
API route handler
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers4. Server Actions
Credential login needs redirect: false so the client can show errors without a full-page redirect. Social OAuth uses redirectTo instead — different flow, different action.
// actions/auth.ts
'use server'
import { signIn, signOut } from '@/auth'
import { AuthError } from 'next-auth'
export async function doCredentialLogin(formData: FormData) {
const email = formData.get('email')
const password = formData.get('password')
if (typeof email !== 'string' || typeof password !== 'string') {
return { error: 'Invalid form submission.' }
}
if (!email.trim() || !password) {
return { error: 'Email and password are required.' }
}
try {
const result = await signIn('credentials', {
email: email.trim(),
password,
redirect: false,
})
if (result?.error) {
return { error: 'Invalid email or password.' }
}
return { success: true }
} catch (error) {
if (error instanceof AuthError) {
return { error: 'Invalid email or password.' }
}
throw error
}
}
export async function doLogout() {
await signOut({ redirectTo: '/login' })
}| Server Action detail | Why it matters |
|---|---|
signIn method call | Entry point for Next-Auth credential flow |
| Credentials provider type | First arg 'credentials' matches provider name |
| Form Data processing | Read email and password from FormData |
redirect: false option | Returns { error } to client instead of redirecting |
5. UI implementation
Credential login form — email & password inputs
The credential form is a client component because it handles submit events, loading state, and inline errors. Keep social buttons in a separate file.
// components/CredentialLoginForm.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { doCredentialLogin } from '@/actions/auth'
export function CredentialLoginForm() {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
async function handleFormSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setPending(true)
const formData = new FormData(event.currentTarget)
const result = await doCredentialLogin(formData)
if (result?.error) {
setError(result.error)
setPending(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<form onSubmit={handleFormSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-semibold">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="w-full rounded-lg border px-4 py-3"
placeholder="jane@example.com"
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-semibold">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="w-full rounded-lg border px-4 py-3"
placeholder="••••••••"
/>
</div>
{error && (
<p
role="alert"
className="rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={pending}
className="w-full rounded-lg border px-4 py-3 font-semibold disabled:opacity-60">
{pending ? 'Signing in…' : 'Sign in'}
</button>
</form>
)
}Social login separation
If your app also offers Google/GitHub, keep OAuth buttons in a separate component and Server Action — not inside CredentialLoginForm. Social login uses redirectTo; credentials use redirect: false. Mixing both in one client form creates conflicting redirect behavior.
// components/SocialLoginSection.tsx
// Lives on the same /login page — separate file from CredentialLoginForm
// Wire up doSocialLogin from the Google & GitHub guide
export function SocialLoginSection() {
return (
<div className="mt-8 border-t pt-8">
<p className="text-center text-sm text-neutral-600">Or continue with social login</p>
{/* Google / GitHub buttons go here — see OAuth guide */}
</div>
)
}Login page
// app/login/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { CredentialLoginForm } from '@/components/CredentialLoginForm'
export default async function LoginPage() {
const session = await auth()
if (session) redirect('/dashboard')
return (
<main className="mx-auto max-w-md px-6 py-20">
<h1 className="text-2xl font-bold">Sign in</h1>
<p className="mt-2 text-neutral-600">Use your email and password.</p>
<div className="mt-8">
<CredentialLoginForm />
{/* <SocialLoginSection /> — optional, separate component */}
</div>
</main>
)
}LogoutButton
// components/LogoutButton.tsx
import { doLogout } from '@/actions/auth'
export function LogoutButton() {
return (
<form action={doLogout}>
<button type="submit" className="rounded-lg border px-4 py-2 text-sm font-semibold">
Sign out
</button>
</form>
)
}6. Client-side logic & UX
The credential flow depends on client-side orchestration because Server Actions with redirect: false return a result object the UI must handle.
handleFormSubmit flow
User submits form
↓
event.preventDefault() ← stop full page reload
↓
new FormData(form) ← collect email + password
↓
doCredentialLogin(formData) ← Server Action
↓
result.error? → setError() ← useState error feedback
result.success → router.push('/dashboard') + router.refresh()| Client technique | Purpose |
|---|---|
handleFormSubmit | Async wrapper around Server Action call |
event.preventDefault() | Keep SPA behavior; show inline errors |
| FormData API | Read email and password by name attribute |
useState error feedback | Display "Invalid email or password" without redirect |
useRouter navigation | Push to /dashboard only after successful login |
| Aesthetic error messages | role="alert", red border, readable copy |
7. Protected routes & dynamic homepage
Protected dashboard
// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { LogoutButton } from '@/components/LogoutButton'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect('/login')
return (
<main className="mx-auto max-w-2xl px-6 py-20">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-4">Welcome back, {session.user.name}</p>
<p className="text-sm text-neutral-600">{session.user.email}</p>
<div className="mt-8">
<LogoutButton />
</div>
</main>
)
}Dynamic homepage view
The homepage renders different UI depending on whether auth() returns a session — no client hook required for the initial view.
// app/page.tsx
import Link from 'next/link'
import { auth } from '@/auth'
import { LogoutButton } from '@/components/LogoutButton'
export default async function HomePage() {
const session = await auth()
return (
<main className="mx-auto max-w-2xl px-6 py-20">
{session?.user ? (
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Signed in as {session.user.name}</p>
<p className="text-sm text-neutral-600">{session.user.email}</p>
<div className="mt-4 flex gap-4">
<Link href="/dashboard" className="font-semibold underline">
Go to dashboard
</Link>
<LogoutButton />
</div>
</div>
) : (
<div>
<p>You are not signed in.</p>
<Link href="/login" className="mt-4 inline-block font-semibold underline">
Sign in with email & password
</Link>
</div>
)}
</main>
)
}8. Production notes & troubleshooting
Swap mock DB for a real database
| Development | Production |
|---|---|
lib/users.ts dummy array | Prisma / Drizzle / SQL query |
getUserByEmail reads array | getUserByEmail queries users table |
| Seed script with bcrypt hashes | Registration API hashes password on create |
// lib/users.ts — production shape (example)
import { db } from '@/lib/db'
export async function getUserByEmail(email: string) {
return db.user.findUnique({ where: { email: email.toLowerCase() } })
}The authorize function and Server Action stay the same — only the data layer changes.
Common errors
| Error | Cause | Fix |
|---|---|---|
CredentialsSignin | Wrong email/password or authorize returned null | Check hash matches; verify getUserByEmail |
Session always null | Missing AUTH_SECRET | Run npx auth secret |
| Form submits but no redirect | Forgot router.push after success | Handle result.success in handleFormSubmit |
authorize never runs | Missing [...nextauth]/route.ts | Export handlers as GET and POST |
| Plain password in DB | Skipped bcrypt | Hash with bcrypt.hash(password, 10) on register |
Security checklist
| Practice | Why |
|---|---|
| bcrypt (cost factor 10+) | Slows brute-force attacks |
return null on failure | No user enumeration |
| Server-side validation | Client required is not enough |
httpOnly session cookie | Next-Auth default — no token in localStorage |
Rate limit /api/auth/* | Slow credential stuffing attacks |
Summary
| Mind map branch | Key pieces |
|---|---|
| UI implementation | CredentialLoginForm, email/password inputs, social separation, dynamic homepage |
| Configuration | Credentials provider, JWT strategy, authorize with validation + bcrypt |
| Mock database | lib/users.ts, dummy array, getUserByEmail helper |
| Server Actions | doCredentialLogin, signIn('credentials'), FormData, redirect: false |
| Client-side logic | handleFormSubmit, preventDefault, error state, useRouter |
That is the complete Next-Auth v5 Credential Provider setup for Next.js App Router — from mock users to production-ready patterns.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime