NextAuth.js Starter Guide: Complete Tutorial on Credentials Login and Session Management

Introduction
The day I opened NextAuth.js documentation, I stared at the overwhelming configuration options for half an hour. Provider, Session, Adapter, JWT, Callbacks… I recognized each word, but together they made no sense. The most frustrating part? The docs say “JWT by default,” but the next paragraph mentions “automatically switches to Session with database” — so which should I use?
To be honest, I almost gave up and switched to Clerk. But then I thought: Clerk is fast, but after 10,000 monthly active users, it costs money, and customization is limited. NextAuth.js has a steeper learning curve but it’s completely free and fully under your control.
This article won’t list every configuration option (that would be torture). Instead, we’ll focus on the most common scenario: Credentials login — username/password authentication, session management, and database integration. Once you understand these core concepts, NextAuth.js becomes much easier.
Understanding NextAuth.js Core Concepts
What Does NextAuth.js Actually Do?
In one sentence: NextAuth.js is an “authentication middleware” that manages “who’s logged in” and “how login state is stored.” It doesn’t care about your database or UI—it only handles user identity verification and login state persistence.
How does it compare to Clerk and Supabase Auth?
- Clerk: Beautiful login UI, user management dashboard, 30-minute setup—but costs money (above 10,000 MAU)
- Supabase Auth: If you use Supabase database, authentication is essentially free and integration is seamless
- NextAuth.js: Completely free and open source, but you build your own login UI, registration, and database logic
The 2025 trend is interesting—NextAuth.js is still popular, but “ready-to-use” solutions like Clerk are gaining market share fast. I get it—spending days writing auth code when you could focus on core features. But if it’s a personal project or you want full control, NextAuth.js is worth learning.
Three Essential Concepts
1. Provider: How do users log in?
It’s the login method. NextAuth.js supports 50+ providers, most common:
- OAuth Providers: Google, GitHub, Facebook… one-click login, you don’t handle passwords
- Credentials Provider: Username + password, the traditional way, and this article’s focus
Here’s the catch: Credentials provider is the most flexible but requires the most code. The official docs don’t even recommend it because security risks are on you—password encryption, brute-force prevention, session management, all your responsibility.
2. Session: How do you remember users after login?
Users log in once, they shouldn’t re-enter passwords for every request, right? Session is the “remember login state” mechanism. Two approaches:
- JWT Session: Login info encrypted and stored in cookies, server stores nothing
- Database Session: Cookie only stores an ID, actual login info in database
Choosing between these two is what confuses NextAuth.js beginners most. The next section covers this in detail.
3. Adapter: Where is user data stored?
If you want to store user info in a database (like email, registration time), you need an Adapter. NextAuth.js supports Prisma, MongoDB, MySQL, and various databases.
But here’s the key: Credentials provider doesn’t automatically store user info in database. The official docs clearly state: with Credentials, you manage user accounts yourself. NextAuth.js only handles login verification, not registration and storage.
JWT vs Session: Which Should You Choose?
This is the core question. I read a dozen comparison articles and still couldn’t decide, until I understood their fundamental difference.
JWT Session: Passport Model
Imagine traveling abroad—your passport has your photo, name, and expiration date. At border control, they check your passport without querying any system. JWT works the same way—after login, the server generates an encrypted token (like a passport) containing userId, email, etc., then stores it in browser cookies.
Pros:
- Fast. No database query needed, decrypt token to know user identity
- Cost-effective. No database for session storage, ideal for serverless deployment
- Scalable. No worries about session table explosion with high user count
Cons:
- Can’t force logout. Found a compromised account and want to invalidate a login? Sorry, can’t do it until token expires (unless you maintain a blacklist, which requires a database)
- Can’t limit concurrent devices. Want “max 3 simultaneous logins”? Impossible
- Can’t update info before expiration. Stored user role in token and it changed? Until token expires, user sees the old role
Database Session: Hotel Key Card Model
A hotel gives you a key card with just your room number. Your details (passport, charges) are in the hotel system. Each swipe queries your room number to confirm access. Database Session works the same—cookie only stores a session ID, actual user info in database.
Pros:
- Can invalidate logins anytime. User clicks “log out all devices”? Delete database session records
- Can limit login device count
- Can update user info in real-time (like permissions changing, takes effect next request)
Cons:
- Slower. Every request queries database
- Need to manage session table (creation, cleanup of expired sessions)
- With high user count, database pressure increases
Decision Tree (Here’s the Key Part)
You might ask: so which should I choose? My recommendation:
Does your app need "force re-login" functionality? (password changed, account frozen)
├─ Yes → Use Database Session
└─ No → Continue to next question
Want to skip database or use serverless deployment?
├─ Yes → Use JWT
└─ No → Continue to next question
Is your app a personal project/MVP, prioritizing fast launch?
├─ Yes → Use JWT (simple, hassle-free)
└─ No → Use Database Session (more stable for enterprise)My own project started with JWT, then later needed “kick user offline” for admin panel, so I switched to Database Session. The migration cost was high, so I suggest thinking it through from the start.
Special Case: Credentials + Database Session
If you use Credentials provider and want Database Session, you’ll hit a major roadblock: the official docs say it’s not supported.
Specifically, OAuth providers (Google, GitHub) work seamlessly with Database Session, but Credentials doesn’t. The reason: NextAuth.js considers Credentials too flexible to automatically create session records.
Solutions exist, but require manual code in the signIn callback to create sessions yourself. GitHub has many discussions (like this issue) with ready-made solutions. But honestly, if you’re a beginner, I suggest either using JWT or switching to OAuth providers—don’t make it harder on yourself.
Complete Credentials Provider Configuration
Alright, theory done—let’s code. I’ll provide three example versions from simple to complete, choose based on your stage.
Basic Configuration: Minimal Working Version
First install dependencies:
npm install next-authThen create the file. Note: for Next.js 13+ with App Router, path is app/api/auth/[...nextauth]/route.ts; if still using Pages Router, it’s pages/api/auth/[...nextauth].js. I’ll demo with App Router.
app/api/auth/[…nextauth]/route.ts:
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
const handler = NextAuth({
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
// Hardcode a user for testing
if (credentials?.email === "[email protected]" &&
credentials?.password === "123456") {
return {
id: "1",
name: "Test User",
email: "[email protected]"
}
}
return null // Login failed
}
})
],
session: {
strategy: "jwt" // Use JWT session
},
pages: {
signIn: '/login' // Custom login page (optional)
}
})
export { handler as GET, handler as POST }Environment variables .env.local:
NEXTAUTH_SECRET=your-super-secret-key-change-this
NEXTAUTH_URL=http://localhost:3000Key Points:
NEXTAUTH_SECRET: Used to encrypt tokens. Required in production, recommended in local dev too. Generation:openssl rand -base64 32authorizefunction: Core logic for user verification. Return user object for success, null for failure
This version runs, but user data is hardcoded—not practical. Next, we’ll connect to a database.
Key Configuration Explained
Before the complete example, let’s clarify the most confusing configurations.
1. session.strategy: “jwt” or “database”?
As mentioned, default is “jwt”. If you use an Adapter (connecting database), it auto-switches to “database”. But if you use Credentials provider + want JWT, you must explicitly set strategy: "jwt" or it might error.
2. callbacks: How to add custom fields to session?
By default, useSession returns user info with only name, email, image. Want to add userId or role?
Use callbacks:
callbacks: {
async jwt({ token, user }) {
// user only has value on login
if (user) {
token.userId = user.id // Add userId to token
}
return token
},
async session({ session, token }) {
// Put userId from token into session
session.user.userId = token.userId
return session
}
}Now when frontend calls const { data: session } = useSession(), session.user.userId has a value.
3. pages: Custom login page
NextAuth.js has an ugly built-in login page (at /api/auth/signin). If you want your own login UI, set pages: { signIn: '/login' }.
Your login page needs to call:
import { signIn } from "next-auth/react"
const handleSubmit = async (e) => {
e.preventDefault()
const result = await signIn('credentials', {
redirect: false,
email,
password
})
if (result?.error) {
// Login failed
} else {
// Login successful, redirect
}
}Complete Example: Registration + Login + Session Management
Now a real usable version. Assuming you use Prisma + PostgreSQL.
1. Create user table
prisma/schema.prisma:
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
createdAt DateTime @default(now())
}Run npx prisma migrate dev to generate table.
2. Registration endpoint
app/api/register/route.ts:
import { NextResponse } from "next/server"
import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma" // Assuming you have a prisma client
export async function POST(req: Request) {
try {
const { email, password, name } = await req.json()
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return NextResponse.json(
{ error: "Email already registered" },
{ status: 400 }
)
}
// Encrypt password (critical!)
const hashedPassword = await bcrypt.hash(password, 10)
// Create user
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name
}
})
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name
}
})
} catch (error) {
return NextResponse.json(
{ error: "Registration failed" },
{ status: 500 }
)
}
}Critical: Passwords must be encrypted! Use bcrypt or argon2, never store plain text in database.
3. NextAuth configuration (connecting database)
app/api/auth/[...nextauth]/route.ts:
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma"
const handler = NextAuth({
providers: [
CredentialsProvider({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
// Query user from database
const user = await prisma.user.findUnique({
where: { email: credentials.email }
})
if (!user) {
return null // User doesn't exist
}
// Verify password
const isValid = await bcrypt.compare(
credentials.password,
user.password
)
if (!isValid) {
return null // Wrong password
}
// Return user info (don't include password!)
return {
id: user.id,
email: user.email,
name: user.name
}
}
})
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60 // 30 days
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.userId = user.id
}
return token
},
async session({ session, token }) {
session.user.userId = token.userId as string
return session
}
},
pages: {
signIn: '/login'
}
})
export { handler as GET, handler as POST }4. Using session in frontend
In Server Component:
import { getServerSession } from "next-auth"
export default async function ProfilePage() {
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return <div>Welcome, {session.user.name}</div>
}In Client Component:
'use client'
import { useSession } from "next-auth/react"
export default function Dashboard() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div>Loading...</div>
}
if (!session) {
return <div>Please log in</div>
}
return <div>Your user ID: {session.user.userId}</div>
}Note:
- Server-side use
getServerSession() - Client-side use
useSession() - Client components need a
<SessionProvider>wrapper (usually added in layout)
Session Management Strategies & Common Issues
Using JWT Sessions Properly
If you chose JWT, use it right. A few key points:
1. Storing extra info in JWT (userId, role, etc.)
The callbacks example showed this earlier—emphasizing again: jwt callback adds to token, session callback puts token data into session.
Real projects might need to store roles:
callbacks: {
async jwt({ token, user }) {
if (user) {
token.userId = user.id
token.role = user.role // Assuming database has role field
}
return token
},
async session({ session, token }) {
session.user.userId = token.userId as string
session.user.role = token.role as string
return session
}
}2. Token expiration time
Default is 30 days, but you can change it:
session: {
strategy: "jwt",
maxAge: 7 * 24 * 60 * 60 // 7 days
}A gotcha: if user closes browser and reopens, token persists (unless manually logged out). If you want “closing browser logs out,” handle it frontend—backend JWT can’t do that.
3. JWT’s biggest limitation: can’t actively revoke
This is JWT’s most frustrating aspect. Found a compromised account and want to immediately force logout? Sorry, can’t do it. If token hasn’t expired, anyone with it can still access.
Workarounds:
- Set short token expiration (like 1 hour), trade user experience for security
- Maintain a token blacklist (but that requires database, losing JWT’s advantage)
- Use Database Session (one-time solution)
Implementing Database Session (including Credentials provider)
If you chose Database Session from the start, configuration is actually simpler—provided you use OAuth providers (Google, GitHub).
Install an Adapter:
npm install @next-auth/prisma-adapterConfiguration:
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
const handler = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
})
]
// strategy auto-becomes "database", no manual setting
})Database auto-creates User, Session, Account tables.
However, if you use Credentials provider, it gets messy. Official docs clearly state: Credentials provider doesn’t support database session.
Many online solutions exist, core idea is manually creating session records in signIn callback. I don’t recommend this for beginners—too error-prone. If you must use Credentials + Database Session, check these GitHub discussions:
Or consider switching to Clerk or Supabase Auth—they natively support this combination.
Top 5 Most Common Pitfalls
I’ve stepped on all these landmines—they’ll save you time:
1. Forgetting NEXTAUTH_SECRET causes production errors
In local dev, NEXTAUTH_SECRET can be unset (NextAuth.js warns but runs). But deploying to Vercel, Railway, etc. without this environment variable? Straight to 500 error.
Generation method: openssl rand -base64 32, then add to deployment platform’s environment variables.
2. Credentials provider can’t use database session by default
Covered earlier—emphasizing again: if you configured Adapter and use Credentials, you’ll error. Either explicitly set session: { strategy: "jwt" } or manually implement session creation logic.
3. useSession doesn’t work in server components
Next.js 13+ App Router defaults to Server Components. Writing const session = useSession() in a server component errors because hooks only work client-side.
Server-side use getServerSession():
import { getServerSession } from "next-auth"
const session = await getServerSession()Client-side add 'use client' directive, then use useSession().
4. Cookie cross-origin issues
Dev environment localhost:3000, production example.com—different cookie domains cause login state loss.
Solution: Ensure NEXTAUTH_URL environment variable in production is set to correct domain. Don’t hardcode localhost.
5. JWEDecryptionFailed error
Error: JWEDecryptionFailed: decryption operation failed
Cause: You changed NEXTAUTH_SECRET, but browser still has old token. Old token can’t be decrypted with new secret.
Solution: Clear browser cookies or re-login.
Bonus: Using Middleware to protect routes
If you want certain pages to require login, use Middleware:
middleware.ts:
export { default } from "next-auth/middleware"
export const config = {
matcher: ["/dashboard/:path*", "/profile/:path*"]
}This makes all /dashboard and /profile pages check login status, auto-redirecting to login page if not authenticated.
Real-World Recommendations & Learning Path
Which Solution Should I Choose? Decision Recommendations
After all that, let me help you decide.
Scenario 1: Personal project/MVP/blog
- Recommendation: NextAuth.js + JWT + Credentials
- Rationale: Completely free, simple configuration, no session table management
- Downside: If later needing “kick offline” features, migration cost is high
Scenario 2: Enterprise app/SaaS product (with budget)
- Recommendation: Clerk
- Rationale: 30-minute setup, beautiful UI, full user management, saves 40-80 development hours
- Downside: Costs money above 10,000 MAU, limited customization
- Alternative: If using Supabase database, Supabase Auth is great (50,000 MAU free tier)
Scenario 3: Enterprise app (no budget/need full control)
- Recommendation: NextAuth.js + Database Session + OAuth providers (Google/GitHub)
- Rationale: Completely free, full features, can kick users offline
- Downside: Build your own login UI, manage database
Scenario 4: Existing user system, just need auth layer
- Recommendation: NextAuth.js + Credentials + JWT
- Rationale: Flexible, non-invasive to existing database structure
- Note: Security measures (password encryption, brute-force prevention) are your responsibility
My own experience: First project used JWT, six months later requirements changed needing admin user management, migrated to Database Session taking two days. Second project evaluated requirements upfront, went straight to Database Session, saved hassle.
Learning Roadmap
If you’ve got Credentials login working, next steps to learn:
1. OAuth Providers (simpler, recommended priority)
- Google, GitHub login is way simpler than Credentials
- No password encryption, user registration management—OAuth provider handles it
- Better user experience (one-click login)
2. Middleware: Protecting routes
- Demonstrated earlier, protect entire directory with one line of code
- Way more convenient than checking session on every page individually
3. Multi-role Permission Management (RBAC)
- Store
rolefield in session - Display different content or permissions based on role
- Advanced: learn CASL library (fine-grained permission control)
4. Email Verification & Password Reset
- NextAuth.js Email Provider (email-based login)
- Build your own forgot password feature (send reset link)
Reference Resources
Official Docs (must-read):
- NextAuth.js Configuration Options - All config options
- Credentials Provider Documentation - Credentials details
- Session Strategies - JWT vs Database Session official comparison
Practical Tutorials:
- Learn Next.js Chinese Tutorial - Adding Authentication - Chinese tutorial, beginner-friendly
- NextAuth.js JWT Session Beginner Tutorial - Detailed JWT-focused tutorial
GitHub Discussions (search when stuck):
- Database session + Credentials login - Credentials + Database Session solutions
- NextAuth.js FAQ - Official FAQ
Competitor Comparisons (help you choose):
- NextAuth vs Clerk vs Supabase 2025 Comparison - Detailed three-way comparison
Conclusion
After all that, NextAuth.js core boils down to three decisions:
- What login method? Credentials or OAuth? Most of the time OAuth (Google/GitHub) is simpler
- How to store sessions? JWT or Database? Personal projects use JWT, enterprise apps use Database
- How to verify users? Credentials means writing verification logic yourself, OAuth hands it to providers
My advice: Start with the minimal example (this article’s “Minimal Working Version”), get login working, then gradually add features. Don’t try to understand all configurations upfront—that’s discouraging.
NextAuth.js has a learning curve, but once you master it, you fully control the auth flow. If you want speed, Clerk is a great choice; if you want to save money and learn, NextAuth.js is worth the time investment.
Finally, drop your questions in the comments. Which configuration part has you stuck? Still can’t decide between JWT and Session?
Recommended Reading:
- Next article planned: “Complete Next.js Middleware Guide” covering route protection, A/B testing scenarios
- If interested in user permission management, check out my previous “RBAC Permission Design Practice”
FAQ
What's the difference between NextAuth.js, Clerk, and Supabase Auth?
• Completely free open-source self-hosted solution
• Need to implement login UI and user management yourself
• But fully controllable
Clerk:
• Provides complete UI and management dashboard
• But costs money above 10,000 monthly active users
Supabase Auth:
• Suitable for projects using Supabase database
• Simple integration but binds to database
Should I choose JWT or Session?
• Suitable for stateless apps, personal projects
• No database needed
• But cannot revoke individual sessions
Session:
• Suitable for enterprise apps, scenarios needing login revocation
• Need to configure database Adapter
• But can precisely control sessions
Is Credentials login secure?
1) Passwords must be encrypted (use bcrypt, etc.)
2) Validation logic must run on server side
3) Use HTTPS for transmission
4) Implement password strength requirements
5) Consider adding captcha to prevent brute force
How do I customize login pages?
Then create custom login page, use signIn('credentials', { username, password }) to trigger login.
You can also completely customize UI, only using NextAuth.js APIs.
How do I get current logged-in user?
const { data: session } = useSession()
On server: use getServerSession():
const session = await getServerSession(authOptions)
Session object contains user info (id, name, email, etc.).
How do I implement role and permission control?
Then in pages or API routes, check session.user.role, decide access based on role.
You can also use Middleware for global permission checks.
Which databases does NextAuth.js support?
• Prisma (PostgreSQL, MySQL, SQLite, etc.)
• MongoDB
• TypeORM
• Drizzle ORM
With Adapter, automatically switches to Session strategy, session info stored in database.
12 min read · Published on: Dec 19, 2025 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment