Next.js Authentication || Register User To MongoDB With Next-Auth V5
Register users to MongoDB with Mongoose in Next.js App Router — registration UI, /api/register route, bcrypt hashing, user schema, createUser queries, and Next-Auth v5 Credentials login integration.
Introduction
This guide covers registering users to MongoDB and wiring them into Next-Auth v5 credential login. You own the full loop: registration form → API route → hashed password in MongoDB → authorize lookup on sign-in.
Next-Auth does not provide registration — you build /api/register, store users with Mongoose, then point the Credentials provider at the same collection.
Next.js Auth v5 with MongoDB
│
┌─────┬───────┼───────┬─────────┬──────────┐
│ │ │ │ │ │
Reg API MongoDB Data Security Login
UI Route Setup Model Encryption (auth.ts)What you will build:
| Branch | What it covers |
|---|---|
| 1. Registration UI | Register link, /register page, form with name/email/password, redirect to login |
| 2. API route handler | POST /api/register, JSON parsing, try/catch, status codes |
| 3. MongoDB setup | MONGODB_URI, Mongoose install, connection utility |
| 4. Data modeling | User schema, required fields, singleton model export |
| 5. Security & encryption | bcryptjs, bcrypt.hash, salt rounds |
| 6. Database queries | queries/users.ts, createUser, user.create() |
| 7. Login flow integration | auth.ts — findOne, bcrypt.compare, authorize |
Quick index
1. Install & project structure
npm install next-auth@5 mongoose bcryptjs
npm install -D @types/bcryptjs
npx auth secretEnvironment variables
# .env.local
AUTH_SECRET=your_generated_secret_here
MONGODB_URI=mongodb+srv://<user>:<password>@cluster.mongodb.net/myapp| Variable | Purpose |
|---|---|
AUTH_SECRET | Encrypts Next-Auth session JWT |
MONGODB_URI | MongoDB Atlas or local connection string |
Folder structure
├── app/
│ ├── api/
│ │ ├── auth/[...nextauth]/route.ts
│ │ └── register/route.ts # POST — create user
│ ├── login/page.tsx # Link to /register
│ ├── register/page.tsx # Registration page
│ └── dashboard/page.tsx
├── components/
│ ├── RegistrationForm.tsx # Client form
│ ├── CredentialLoginForm.tsx # From credentials guide
│ └── LogoutButton.tsx
├── lib/
│ ├── mongodb.ts # Connection utility
│ └── queries/users.ts # createUser, getUserByEmail
├── models/
│ └── user-model.ts # Mongoose schema
└── auth.ts # Credentials + MongoDB authorize2. MongoDB setup
Connection string in .env
Create a free cluster at MongoDB Atlas, add a database user, whitelist your IP (or 0.0.0.0/0 for development), and copy the connection string into MONGODB_URI.
Mongoose ODM installation
Mongoose is already installed above (mongoose package). It provides schemas, validation, and the create / findOne methods used throughout this guide.
DB connection utility
Next.js hot reload creates new connections on every file save unless you cache the connection globally.
// lib/mongodb.ts
import mongoose from 'mongoose'
const MONGODB_URI = process.env.MONGODB_URI
if (!MONGODB_URI) {
throw new Error('MONGODB_URI is not defined in environment variables')
}
interface MongooseCache {
conn: typeof mongoose | null
promise: Promise<typeof mongoose> | null
}
declare global {
// eslint-disable-next-line no-var
var mongooseCache: MongooseCache | undefined
}
const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null }
global.mongooseCache = cached
export async function connectDB() {
if (cached.conn) return cached.conn
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI)
}
cached.conn = await cached.promise
return cached.conn
}User collection creation
Mongoose creates the users collection automatically on the first User.create() call. No manual collection setup required — the schema in the next section defines the document shape.
3. Data modeling — user schema
Define the user document shape in a dedicated model file. All three fields are required — registration and login depend on them.
// models/user-model.ts
import mongoose from 'mongoose'
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: [true, 'Password is required'],
},
},
{ timestamps: true },
)
// Singleton model export — prevents OverwriteModelError on hot reload
export const User = mongoose.models.User ?? mongoose.model('User', userSchema)| Field | Type | Rules |
|---|---|---|
| name | String | Required, trimmed |
| String | Required, unique, lowercase | |
| password | String | Required — stores bcrypt hash |
4. Security & encryption
Passwords must be hashed before they touch MongoDB. Use bcryptjs with a salt rounds value of 10 — the industry default for web apps.
import bcrypt from 'bcryptjs'
const SALT_ROUNDS = 10
const hashedPassword = await bcrypt.hash(plainPassword, SALT_ROUNDS)
// → store hashedPassword in MongoDB, discard plainPassword| Topic | Detail |
|---|---|
| bcryptjs library | Pure JS bcrypt — works in Next.js Route Handlers without native bindings |
| bcrypt.hash | One-way transform; cannot recover original password |
| Salt rounds (10) | ~100ms per hash — slows brute-force without hurting UX on register/login |
5. Database queries
Keep database operations in a dedicated queries file — API routes and auth.ts import helpers instead of writing Mongoose calls inline.
// lib/queries/users.ts
import { connectDB } from '@/lib/mongodb'
import { User } from '@/models/user-model'
type CreateUserInput = {
name: string
email: string
password: string // already hashed
}
export async function createUser({ name, email, password }: CreateUserInput) {
await connectDB()
return User.create({ name, email, password })
}
export async function getUserByEmail(email: string) {
await connectDB()
return User.findOne({ email: email.toLowerCase() }).lean()
}| Function | Mongoose method | Used by |
|---|---|---|
| createUser | User.create() | POST /api/register |
| getUserByEmail | User.findOne() | auth.ts authorize |
6. API route handler
The register endpoint receives JSON from the registration form, validates input, hashes the password, and persists the user.
// app/api/register/route.ts
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { createUser } from '@/lib/queries/users'
const SALT_ROUNDS = 10
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, email, password } = body
// Validate payload
if (!name?.trim() || !email?.trim() || !password) {
return NextResponse.json({ error: 'Name, email, and password are required.' }, { status: 400 })
}
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters.' }, { status: 400 })
}
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS)
await createUser({
name: name.trim(),
email: email.trim().toLowerCase(),
password: hashedPassword,
})
return NextResponse.json({ message: 'Account created successfully.' }, { status: 201 })
} catch (error) {
// Duplicate email — MongoDB unique index violation
if (error instanceof Error && 'code' in error && (error as { code: number }).code === 11000) {
return NextResponse.json({ error: 'An account with this email already exists.' }, { status: 409 })
}
console.error('[POST /api/register]', error)
return NextResponse.json({ error: 'Something went wrong. Please try again.' }, { status: 500 })
}
}NextResponse status codes
| Status | When |
|---|---|
| 201 | User created successfully |
| 400 | Missing or invalid fields |
| 409 | Email already registered (duplicate key) |
| 500 | Unexpected server error |
7. Registration UI
Registration link on login page
// app/login/page.tsx
import Link from 'next/link'
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>
<div className="mt-8">
<CredentialLoginForm />
</div>
<p className="mt-6 text-center text-sm text-neutral-600">
Don't have an account?{' '}
<Link href="/register" className="font-semibold underline">
Create one
</Link>
</p>
</main>
)
}Register route
// app/register/page.tsx
import Link from 'next/link'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { RegistrationForm } from '@/components/RegistrationForm'
export default async function RegisterPage() {
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">Create account</h1>
<p className="mt-2 text-neutral-600">Register with your name, email, and password.</p>
<div className="mt-8">
<RegistrationForm />
</div>
<p className="mt-6 text-center text-sm text-neutral-600">
Already have an account?{' '}
<Link href="/login" className="font-semibold underline">
Sign in
</Link>
</p>
</main>
)
}Registration form component
Client component with handleSubmit, fetch to the API, error state, and redirect to login on success.
// components/RegistrationForm.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function RegistrationForm() {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setPending(true)
const formData = new FormData(event.currentTarget)
const name = formData.get('name') as string
const email = formData.get('email') as string
const password = formData.get('password') as string
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error ?? 'Registration failed.')
setPending(false)
return
}
// Router redirect — send user to login after successful registration
router.push('/login?registered=true')
} catch {
setError('Network error. Please try again.')
setPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="name" className="mb-1 block text-sm font-semibold">
Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
className="w-full rounded-lg border px-4 py-3"
placeholder="Jane Doe"
/>
</div>
<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="new-password"
required
minLength={8}
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 ? 'Creating account…' : 'Create account'}
</button>
</form>
)
}| Form feature | Implementation |
|---|---|
| Input fields | name, email, password with labels and autoComplete |
| Submit button | Triggers handleSubmit → POST /api/register |
| handleSubmit | preventDefault, fetch JSON payload, error handling |
| Router redirect | router.push('/login?registered=true') on 201 |
8. Login flow integration — auth.ts
After registration, users sign in through the same Credentials provider — but authorize now queries MongoDB instead of a mock array.
// auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { getUserByEmail } from '@/lib/queries/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) {
if (!credentials?.email || !credentials?.password) {
return null
}
const email = String(credentials.email)
const password = String(credentials.password)
// User lookup — MongoDB findOne by email
const user = await getUserByEmail(email)
if (!user) return null
// Password verification — bcrypt.compare plain vs stored hash
const passwordsMatch = await bcrypt.compare(password, user.password)
if (!passwordsMatch) return null
return {
id: user._id.toString(),
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 method logic
signIn('credentials', { email, password })
↓
authorize(credentials)
↓
getUserByEmail(email) → User.findOne() in MongoDB
↓
bcrypt.compare(password, user.password)
↓
return { id, name, email } → JWT session created| Step | Method | On failure |
|---|---|---|
| User lookup | getUserByEmail → User.findOne() | return null |
| Password verification | bcrypt.compare() | return null |
| Success | Return user object for JWT | — |
API route for Next-Auth
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers9. Production notes & troubleshooting
End-to-end flow
/register → POST /api/register → bcrypt.hash → User.create() → redirect /login
/login → signIn('credentials') → authorize → findOne → bcrypt.compare → session
/dashboard → auth() → protectedBefore going live
| Step | Action |
|---|---|
| 1 | Set MONGODB_URI and AUTH_SECRET in production env vars |
| 2 | Restrict MongoDB Atlas IP allowlist to your host |
| 3 | Ensure unique index on email (Mongoose unique: true handles this) |
| 4 | Test register → login → dashboard flow |
| 5 | Verify duplicate email returns 409, not 500 |
Common errors
| Error | Cause | Fix |
|---|---|---|
MONGODB_URI is not defined | Missing env var | Add to .env.local and Vercel settings |
OverwriteModelError | Model re-registered on hot reload | Use singleton export pattern |
E11000 duplicate key | Email already exists | Return 409 in catch block |
| Login fails after register | Plain password saved instead of hash | Hash in API route before createUser |
Too many connections | No connection cache | Use lib/mongodb.ts global cache |
Summary
| Mind map branch | Key files |
|---|---|
| Registration UI | RegistrationForm.tsx, /register/page.tsx, login page link |
| API route handler | app/api/register/route.ts — POST, JSON, try/catch, 201/400/409/500 |
| MongoDB setup | MONGODB_URI, lib/mongodb.ts, Mongoose install |
| Data modeling | models/user-model.ts — name, email, password, singleton export |
| Security & encryption | bcryptjs, bcrypt.hash, salt rounds 10 |
| Database queries | lib/queries/users.ts — createUser, getUserByEmail |
| Login flow integration | auth.ts — authorize, findOne, bcrypt.compare |
That is the complete register user to MongoDB + Next-Auth v5 pipeline — from sign-up form to credential login against a real database.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime