NextAuth.js Authentication Series — Part 7 ~ Building a Secure User Registration System with MongoDB and bcrypt
In the previous article, we secured our Credentials Authentication system by introducing bcrypt password hashing.Our application can now verify passwords...

In the previous article, we secured our Credentials Authentication system by introducing bcrypt password hashing.
Our application can now verify passwords without storing them in plain text. However, there's still one important piece missing: users cannot create their own accounts.
In this article, we'll build a complete user registration system using Next.js, MongoDB, and bcrypt.
By the end of this article, users will be able to:
Create an account with their name, email, and password
Receive validation errors for invalid input
Register only once with a unique email
Have their password securely hashed
Store their account in MongoDB
Sign in using the Credentials Provider
Registration Flow
Before writing the code, let's understand the complete registration flow.
User
│
▼
Registration Form
│
▼
Validate Input
│
▼
Check Existing User
│
├── Already Exists ──► Return Error
│
▼
Hash Password
│
▼
Create MongoDB User
│
▼
Registration Successful
│
▼
Redirect to Login
The important part is that the password is hashed before it reaches the database.
Step 1 — Install Zod
We'll use Zod for input validation.
Install it with:
npm install zod
Zod allows us to define exactly what valid registration data should look like.
Step 2 — Create a Registration Schema
Create:
lib/validations/auth.ts
Add:
import { z } from "zod";
export const registerSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name is too long"),
email: z
.string()
.email("Please enter a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password is too long"),
});
Now we have a reusable validation schema.
Step 3 — Create the Registration API
With the App Router, we can create a Route Handler.
Create:
app/api/auth/register/route.ts
Add:
import { NextResponse } from "next/server";
import { connectDB } from "@/lib/mongodb";
import { hashPassword } from "@/lib/password";
import { registerSchema } from "@/lib/validations/auth";
export async function POST(request: Request) {
try {
const body = await request.json();
const result = registerSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
message: "Invalid input",
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const { name, email, password } = result.data;
const db = await connectDB();
const existingUser = await db.collection("users").findOne({
email,
});
if (existingUser) {
return NextResponse.json(
{
message: "A user with this email already exists",
},
{ status: 409 }
);
}
const hashedPassword = await hashPassword(password);
await db.collection("users").insertOne({
name,
email,
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
});
return NextResponse.json(
{
message: "User registered successfully",
},
{ status: 201 }
);
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
{
message: "Something went wrong",
},
{ status: 500 }
);
}
}
Let's break down what this endpoint does.
Step 4 — Validate the Request
First, we read the request body:
const body = await request.json();
Then validate it:
const result = registerSchema.safeParse(body);
If validation fails:
if (!result.success) {
return NextResponse.json(
{
message: "Invalid input",
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
This prevents invalid data from reaching the database.
Step 5 — Check for Existing Users
Before creating a new account, we check whether the email already exists:
const existingUser = await db.collection("users").findOne({
email,
});
If a user exists:
if (existingUser) {
return NextResponse.json(
{
message: "A user with this email already exists",
},
{ status: 409 }
);
}
The 409 Conflict status communicates that the request conflicts with existing data.
Step 6 — Hash the Password
Never insert the original password directly into MongoDB.
Instead:
const hashedPassword = await hashPassword(password);
Our database will receive something like:
$2b$12$...
instead of:
myPassword123
Step 7 — Create the User
Now we can create the user:
await db.collection("users").insertOne({
name,
email,
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
});
Notice that we store the hashed password rather than the original password.
Step 8 — Create the Registration Form
Now let's create a simple registration page.
Create:
app/register/page.tsx
"use client";
import { FormEvent, useState } from "react";
export default function RegisterPage() {
const [form, setForm] = useState({
name: "",
email: "",
password: "",
});
const [message, setMessage] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setMessage("");
const response = await fetch("/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
const data = await response.json();
if (!response.ok) {
setMessage(data.message || "Registration failed");
return;
}
setMessage("Account created successfully!");
}
return (
<main>
<h1>Create Account</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Name"
value={form.name}
onChange={(event) =>
setForm({
...form,
name: event.target.value,
})
}
/>
<input
type="email"
placeholder="Email"
value={form.email}
onChange={(event) =>
setForm({
...form,
email: event.target.value,
})
}
/>
<input
type="password"
placeholder="Password"
value={form.password}
onChange={(event) =>
setForm({
...form,
password: event.target.value,
})
}
/>
<button type="submit">
Create Account
</button>
</form>
{message && <p>{message}</p>}
</main>
);
}
Now visit:
http://localhost:3000/register
You should see the registration form.
Step 9 — Test Registration
Try registering with:
Name: John Doe
Email: john@example.com
Password: password123
If everything is configured correctly, the API should return:
{
"message": "User registered successfully"
}
Now check MongoDB.
The user document should look similar to:
{
"_id": "...",
"name": "John Doe",
"email": "john@example.com",
"password": "$2b$12$...",
"createdAt": "...",
"updatedAt": "..."
}
The password should not appear as plain text.
Step 10 — Test Duplicate Registration
Try registering the same email again.
john@example.com
The API should reject the request:
{
"message": "A user with this email already exists"
}
This protects the application from duplicate accounts.
Step 11 — Add a Unique MongoDB Index
Application-level duplicate checking is useful, but the database should also enforce uniqueness.
Create the index:
await db.collection("users").createIndex(
{ email: 1 },
{ unique: true }
);
You should ideally create this index as part of your database initialization/setup rather than recreating it on every registration request.
The unique index provides an additional layer of protection against duplicate emails.
Step 12 — Connect Registration with Login
Now that users can register, the Credentials Provider from the previous articles can authenticate them.
The complete flow becomes:
┌──────────────┐
│ Register │
└──────┬───────┘
│
▼
Validate Input
│
▼
Hash Password
│
▼
MongoDB
│
│
▼
┌──────────────┐
│ Login │
└──────┬───────┘
│
▼
Find User
│
▼
bcrypt.compare()
│
▼
Session
We now have the basic foundation of a real authentication system.
Important Security Considerations
Our implementation is much safer than the original hardcoded example, but production applications need additional protections.
Don't Return Sensitive Data
Registration responses shouldn't return:
{
"password": "...",
"passwordHash": "..."
}
Only return information that the client actually needs.
Don't Reveal Too Much Information
Be careful with authentication-related error messages.
For example, depending on the application's security requirements, revealing whether a specific email exists can allow account enumeration.
For a simple tutorial, explicit duplicate-email feedback is useful. For a high-security production application, you may want more generic messaging.
Rate Limiting
A production registration endpoint should also have rate limiting.
Without rate limiting, an attacker could repeatedly send registration requests.
We'll discuss rate limiting and other production protections later in the series.
Project Structure
Our project now looks like:
app/
├── api/
│ └── auth/
│ ├── [...nextauth]/
│ │ └── route.ts
│ │
│ └── register/
│ └── route.ts
│
├── register/
│ └── page.tsx
│
lib/
├── auth.ts
├── mongodb.ts
├── password.ts
└── validations/
└── auth.ts
This structure keeps authentication-related functionality organized and easier to maintain.
Key Takeaways
In this article, we built a complete basic registration flow.
We learned how to:
Validate registration data with Zod.
Create a registration API.
Check for existing users.
Hash passwords with bcrypt.
Store users in MongoDB.
Create a registration form.
Prevent duplicate emails.
Add database-level uniqueness.
Connect registration with our existing Credentials authentication.
Our authentication system now supports both:
Sign Up
+
Sign In
What's Next?
Users can now register and sign in, but we haven't yet discussed how Auth.js manages their authentication state.
In the next article, we'll explore JWT and Session Management.
We'll learn:
What a session is
How Auth.js manages sessions
JWT vs database sessions
sessionandjwtcallbacksAdding custom user data to sessions
Accessing the authenticated user
Why sensitive information shouldn't be exposed in the session
This will help us understand what happens after a successful login.
Series Navigation
NextAuth.js Authentication Series
✅ Part 1 — Introduction to Authentication with NextAuth.js
✅ Part 2 — Installing & Configuring NextAuth.js
✅ Part 3 — Google OAuth Authentication
✅ Part 4 — Credentials Provider with Email & Password
✅ Part 5 — Integrating MongoDB with Credentials Authentication
✅ Part 6 — Secure Password Authentication with bcrypt
✅ Part 7 — Building a User Registration System ← You are here


