Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
Next.jsNext-AuthCredentialsEmail LoginApp Router

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.

Jul 6, 202614 min read

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

plaintext
        Next-Auth V5 Credential Login
                    │
    ┌───────┬───────┼───────┬───────────┐
    │       │       │       │           │
   UI    Config  Mock DB  Server     Client
  Impl  (auth.ts)        Actions    Logic

What you will build:

BranchWhat it covers
1. UI implementationCredential login form, email/password inputs, dynamic homepage, social login separation
2. ConfigurationCredentials provider, JWT strategy, authorize function
3. Mock databaseUsers data file, getUserByEmail helper, dummy user array
4. Server ActionsdoCredentialLogin with signIn, FormData, redirect: false
5. Client-side logichandleFormSubmit, error state, useRouter navigation

Note

Credentials login is for apps you control end-to-end. Next-Auth does not store users for you — you own registration, password hashing, and the database lookup inside authorize. For Google/GitHub OAuth, see Next.js Authentication With Next-Auth V5 || Google & GitHub.

Quick index

#Section
1Install & project structure
2Mock database
3Core configuration — auth.ts
4Server Actions
5UI implementation
6Client-side logic & UX
7Protected routes & dynamic homepage
8Production notes & troubleshooting

1. Install & project structure

bash
npm install next-auth@5 bcryptjs
npm install -D @types/bcryptjs
npx auth secret

bcryptjs hashes and compares passwords inside authorize — never store plain-text passwords, even in a mock database.

Folder structure

plaintext
├── 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 data

Tip

Keep the credential form and social login buttons in separate components. Mixing them in one file gets messy when credential login needs client-side error state but social login uses simple form actions with redirects.

Back to index


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

ts
// 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

js
// scripts/hash-password.mjs
import bcrypt from 'bcryptjs'
 
const password = process.argv[2] ?? 'password123'
const hash = await bcrypt.hash(password, 10)
console.log(hash)
bash
node scripts/hash-password.mjs password123
# Paste output into lib/users.ts

Note

The getUserByEmail helper is async even with an in-memory array so you can swap it for a real database call later without changing authorize or Server Actions.

Warning

Do not commit real user passwords or production hashes to public repos. The dummy array above is for local development only. In production, users are created through a registration flow and passwords are hashed at insert time.

Back to index


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

ts
// 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

StepResponsibilityOn failure
Credentials argumentEnsure email and password existthrow new Error(...) for missing fields
User validation logicgetUserByEmail(email)return null
Password matchingbcrypt.compare(plain, hash)return null
SuccessReturn { id, name, email }—

Note

Return null for wrong email or wrong password — never reveal which one failed. That prevents user enumeration attacks. Use throw new Error only for malformed requests (empty fields), not for failed logins.

API route handler

ts
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
 
export const { GET, POST } = handlers

Back to index


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

ts
// 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 detailWhy it matters
signIn method callEntry point for Next-Auth credential flow
Credentials provider typeFirst arg 'credentials' matches provider name
Form Data processingRead email and password from FormData
redirect: false optionReturns { error } to client instead of redirecting

Warning

Without redirect: false, a failed login redirects to the error page and you lose client-side error UX. Credentials login almost always needs redirect: false + manual router.push on success.

Tip

Validate email/password on the server even if the client form has required attributes. Browser validation is UX — server validation is security.

Back to index


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.

tsx
// 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.

tsx
// 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>
  )
}

Note

Social buttons use Server Actions with redirectTo — no client state needed. Credential form uses handleFormSubmit + redirect: false. See Next.js Authentication With Next-Auth V5 || Google & GitHub for the OAuth half.

Login page

tsx
// 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

tsx
// 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>
  )
}

Back to index


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

plaintext
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 techniquePurpose
handleFormSubmitAsync wrapper around Server Action call
event.preventDefault()Keep SPA behavior; show inline errors
FormData APIRead email and password by name attribute
useState error feedbackDisplay "Invalid email or password" without redirect
useRouter navigationPush to /dashboard only after successful login
Aesthetic error messagesrole="alert", red border, readable copy

Tip

Call router.refresh() after router.push() so Server Components on the destination page see the new session immediately. Without it, the dashboard might briefly show stale unauthenticated content.

Performance

Disable the submit button with a pending state while the Server Action runs. Prevents double submissions that fire two authorize calls and confuse users with duplicate error flashes.

Back to index


7. Protected routes & dynamic homepage

Protected dashboard

tsx
// 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.

tsx
// 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>
  )
}

Note

Credential sessions do not include a profile image unless you add one to the user record and map it in the jwt / session callbacks. OAuth users get avatars automatically; credential users need you to store and return an image field.

Back to index


8. Production notes & troubleshooting

Swap mock DB for a real database

DevelopmentProduction
lib/users.ts dummy arrayPrisma / Drizzle / SQL query
getUserByEmail reads arraygetUserByEmail queries users table
Seed script with bcrypt hashesRegistration API hashes password on create
ts
// 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

ErrorCauseFix
CredentialsSigninWrong email/password or authorize returned nullCheck hash matches; verify getUserByEmail
Session always nullMissing AUTH_SECRETRun npx auth secret
Form submits but no redirectForgot router.push after successHandle result.success in handleFormSubmit
authorize never runsMissing [...nextauth]/route.tsExport handlers as GET and POST
Plain password in DBSkipped bcryptHash with bcrypt.hash(password, 10) on register

Warning

Next-Auth Credentials provider does not support the "database" session strategy — only "jwt". Plan accordingly if you need server-side session revocation lists.

Security checklist

PracticeWhy
bcrypt (cost factor 10+)Slows brute-force attacks
return null on failureNo user enumeration
Server-side validationClient required is not enough
httpOnly session cookieNext-Auth default — no token in localStorage
Rate limit /api/auth/*Slow credential stuffing attacks

Interview Answer

"How do you implement email/password login with Next-Auth v5?"

Add Credentials provider in auth.ts with an authorize function that looks up the user by email, compares the password with bcrypt.compare, and returns the user object or null. Use JWT sessions. Create a Server Action doCredentialLogin that calls signIn('credentials', { email, password, redirect: false }). Build a client CredentialLoginForm with handleFormSubmit, FormData, useState for errors, and useRouter to navigate on success. Never store plain-text passwords.

Back to index


Summary

Mind map branchKey pieces
UI implementationCredentialLoginForm, email/password inputs, social separation, dynamic homepage
ConfigurationCredentials provider, JWT strategy, authorize with validation + bcrypt
Mock databaselib/users.ts, dummy array, getUserByEmail helper
Server ActionsdoCredentialLogin, signIn('credentials'), FormData, redirect: false
Client-side logichandleFormSubmit, 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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime