Next.js Authentication with NextAuth, Prisma, and MongoDB

In this article, we'll build a robust authentication system for a Next.js application using Auth.js (formerly NextAuth), Prisma ORM, and Mongodb. We'll cover everything from setting up the project to integrating social authentication providers like Google, GitHub, and Credentials signin.
Prerequisites
- Node.js (v20+)
- npm or yarn
- MongoDB Atlas account
- Google and GitHub developer accounts for OAuth credentials
Project Setup
1. Initialize Next.js Project
npx create-next-app@latest next-auth-app
cd next-auth-app
2. Install Dependencies
We'll install the necessary packages for authentication and database integration.
npm install next-auth@beta @prisma/client @auth/prisma-adapter
npm install -D prisma
3. Initialize Prisma
npx prisma init --datasource-provider mongodb
This command creates a prisma directory with a schema.prisma file and a .env file.
4. Configure Mongodb Atlas
-
Log in to Mongodb Atlas and create a new project named "your project name"
-
Create a free-tier cluster and name it accordingly.
-
Set up network access to allow connections from anywhere.
-
Create a database user with a secure password.
-
Obtain the connection string and update the .env file:
DATABASE_URL="mongodb+srv://<username>:<password>@cluster0.mongodb.net/<database>?retryWrites=true&w=majority"
Replace<username>, <password>, and <database> with your actual credentials.
5. Update Prisma Schema
In prisma/schema.prisma define your data models. Here's an example:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
accounts Account[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
type String
provider String
providerAccountId String
refresh_token String? @db.String
access_token String? @db.String
expires_at Int?
token_type String?
scope String?
id_token String? @db.String
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
6. Prevent Multiple Prisma Instances
We must prevent multiple Prisma client instances from being created during hot reloads in development. Create a db.js file in the lib folder and paste this code:
import { PrismaClient } from "@prisma/client";
const db = globalThis.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalThis.prisma = db;
export default db;
So why do we need this? Every time Next.js does a hot reload during development, it creates a new Prisma client instance. If we don’t handle this properly, it can lead to too many database connections, eventually causing errors like:
This is because every file that imports Prisma will create a new instance of PrismaClient(), which isn't an issue in production but can crash your app in development.
if (process.env.NODE_ENV !== "production") globalThis.prisma = db;
This ensures that Prisma is stored globally in development mode. The next time the file is imported (after a hot reload), it reuses the existing instance instead of creating a new one.
7. Generate & Push Schema to Database
Run the commands in the terminal
npx prisma generate
and then
npx prisma db push
If you encounter an error regarding the database name, ensure it's included in your DATABASE_URL.
Authentication Configuration
1. Create Authentication Configuration
A. In the root directory, create auth.js and add this code. Here, we need to configure the Prisma adapter by importing it and passing the db instance. Additionally, set the session strategy to jwt for authentication token management.
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import authConfig from "@/auth.config";
import db from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: "jwt" },
...authConfig,
});
B. Next, In the root directory, create auth.config.js: In this file, we'll define our authentication provider. First, we'll implement social authentication using Google and Github as providers and pass the CLIENT_ID and CLIENT_SECRET from the .env file
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
export default {
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
};
2. Create API Route for Authentication
In app/api/auth/[...nextauth]/route.js: add this code
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
3. Set Environment Variables
Update your .env file with the following:
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
AUTH_SECRET=your-auth-secret
Replace the placeholders with your actual credentials.
A. Google Authentication
- Go to the Google Cloud Console.
- Create a new project and navigate to
APIs & Services" > "Credentials. - Set up the OAuth consent screen.
- Create new OAuth credentials.
- Add http://localhost:3000 as an authorized origin and http://localhost:3000/api/auth/callback/google as a redirect URI.
- Obtain the Client ID and Secret and update your .env file accordingly.
B. GitHub Authentication
- Go to GitHub Developer Settings.
- Create a new OAuth application.
- Set the homepage URL to http://localhost:3000 and the callback URL to http://localhost:3000/api/auth/callback/github.
- Obtain the Client ID and Secret and update your .env file accordingly.
4. Protect Routes with Middleware
Create middleware.js: This middleware function will run before every request and will protect some routes for authenticated user.
import authConfig from "@/auth.config";
import NextAuth from "next-auth";
const { auth } = NextAuth(authConfig);
const authRoutes = ["/", "/login", "/register", "/error"];
const apiAuthPrefix = "/api/auth";
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const isApiAuthRoute = nextUrl.pathname.startsWith(apiAuthPrefix);
const isAuthRoute = authRoutes.includes(nextUrl.pathname);
if (isApiAuthRoute) {
return null;
}
if (isAuthRoute) {
if (isLoggedIn) {
return Response.redirect(new URL("/dashboard", nextUrl));
}
return null;
}
if (!isLoggedIn) {
return Response.redirect(new URL("/login", nextUrl));
}
return null;
});
export const config = {
matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};
the matcher in middleware invoke the middleware functions in every request and Matches all pages except those with file extensions (.css, .js, .png, etc.) and Next.js internal _next files.
Handling Sessions and JWT
In auth.js, extend the config to manage session and JWT callbacks. This helps to persist additional user info in the session and token. Callbacks in NextAuth allow us to modify the authentication process at different stages. There are two key callbacks here:
- session → Runs when a session is created or accessed
- jwt → Runs when a JWT token is created or updated
These callbacks help store user data and ensure data consistency.
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import authConfig from "@/auth.config";
import db from "@/lib/db";
import { getUserById } from "@/utils/users";
export const { handlers, auth, signIn, signOut } = NextAuth({
pages: {
signIn: "/login",
error: "/error",
},
events: {
async linkAccount({ user }) {
await db.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
},
});
},
},
callbacks: {
async session({ session, token }) {
if (token.sub && session.user) {
session.user.id = token.sub;
}
if (token.role && session.user) {
session.user.role = token.role;
}
return session;
},
async jwt({ token }) {
if (!token.sub) return token;
const existingUser = await getUserById(token.sub);
if (!existingUser) {
return token;
}
token.role = existingUser.role;
return token;
},
},
adapter: PrismaAdapter(db),
session: { strategy: "jwt" },
...authConfig,
});
When we authenticate using Google, GitHub, or any other social login, these platforms already verify the email behind the scenes, so we don’t need to verify it again.
Instead, we’ll automatically verify the email whenever a user signs in using a social provider. We can handle this using events in NextAuth, specifically the linkAccount event. This event is triggered whenever a user signs in with a third-party provider like Google, GitHub, or Twitter. Inside this event, we’ll receive the user object as a parameter and update the user’s email verification status. You can also playaround whatever you want to do.
Creating a Login Page
Let’s create a simple login page where users can authenticate via Google or GitHub.
** app/login/page.jsx:**
"use client";
import { signIn } from "next-auth/react";
import { Button } from "@/components/ui/button"; // assuming you're using shadcn
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white shadow-lg rounded-lg p-8 space-y-4">
<h1 className="text-2xl font-bold">Login</h1>
<Button onClick={() => signIn("google")}>Sign in with Google</Button>
<Button onClick={() => signIn("github")}>Sign in with GitHub</Button>
</div>
</div>
);
}
and you'll be successfully signed in. We logged in with Google. Now If you try to authenticate with Github you probably encounter this error but don’t worry, this is expected.
To confirm your identity, sign in with the same account you used originally.
The issue occurs because I'm already signed in with Google, which uses the same email as GitHub. In most applications, we don’t allow multiple users with the same email address. Since the email must be unique, and I’ve already signed in with this email via Google, that's why we see this error. If you were using a different email for GitHub, this error wouldn’t happen.
Notice the default error page generated by NextAuth. and If you look at the address bar, you’ll see the error type: OAuthAccountNotLinked
To replace this default error page, you can customize it using NextAuth’s pages property. you can pass your own sign-in page and error page.
export const { handlers, auth, signIn, signOut } = NextAuth({
pages: {
signIn: "/login",
error: "/error",
},
});
Show user data on UI:
There are two types of component in Next.js, Server component & Client Component. In server-component we can access current user like this:
import React from "react";
const Dashboard = async () => {
const session = await auth();
return <h2>{session?.user?.name}</h2>;
};
export default Dashboard;
But in client-component we need session-provider. let's create a custom hook inside the hooks directory, create a new file called useCurrentUser.js and add this code:
import { useSession } from "next-auth/react";
export const useCurrentUser = () => {
const session = useSession();
return session.data?.user;
};
But keep in mind, since this is a hook, we can’t use it outside of a client component. To make it work properly, we need to wrap our app with the SessionProvider. Go to the layout.js file. Here, extract the session from auth and wrap the entire application with SessionProvider, passing the session as a prop.
import { auth } from "@/auth";
import Sidebar from "@/components/shared/Sidebar/Sidebar";
import Topbar from "@/components/shared/Topbar/Topbar";
import { SessionProvider } from "next-auth/react";
export default async function ProtectedLayout({ children }) {
const session = await auth();
return (
<SessionProvider session={session}>
<section>
<div className="flex">
<aside className="w-[220px] h-screen border-r">
<Sidebar />
</aside>
<div className="w-[calc(100%-220px)] bg-[#F2F4F7] dark:bg-background">
<Topbar />
<div>{children}</div>
</div>
</div>
</section>
</SessionProvider>
);
}
Now we can access the current user in any client component like this:
const currentUser = useCurrentUser();
However, since the profile image comes from a third-party source, we need to configure the hostname in Next.js. To do this, open next.config.js and add the remotePatterns.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: "lh3.googleusercontent.com",
},
{
hostname: "avatars.githubusercontent.com",
},
],
},
};
export default nextConfig;
For Google, the hostname is lh3.googleusercontent.com.
For GitHub, the hostname is avatars.githubusercontent.com.
5. Implement Credentials Signin
A. Creating Server Actions for Registration and Login: To handle authentication with credentials, we need two key server actions: one for registering new users and another for logging in. These actions will be responsible for securely interacting with our database (via Prisma), hashing passwords, and verifying user credentials.
1. Register Server Action: This action handles user sign-up. It hashes the password and stores the new user in the database.
2. Login Server Action: This verifies the user’s email and password. If valid, it returns the user object to be consumed by the NextAuth credentials provider.
"use server";
import bcrypt from "bcryptjs";
import db from "@/lib/db";
import { getUserByEmail } from "@/utils/users";
import { signIn } from "@/auth";
import { AuthError } from "next-auth";
import { signupSchema, loginSchema } from "@/schema/validationSchema";
import { z } from "zod";
// REGISTER
export async function register(data) {
try {
// Validate the input data with Zod
const { name, email, password } = data;
const salt = await bcrypt.genSalt(10);
const hashPassword = await bcrypt.hash(password, salt);
const existingUser = await getUserByEmail(email);
if (existingUser) {
return { status: 500, message: "Email already in use" };
}
await db.user.create({
data: {
name,
email,
password: hashPassword,
},
});
return { status: 200, message: "Successfully signed up" };
} catch (error) {
if (error instanceof z.ZodError) {
return {
status: 400,
message: error.errors.map((e) => e.message).join(", "),
};
}
console.log(error);
return { status: 500, message: "An unexpected error occurred" };
}
}
// LOGIN
export async function login(data) {
try {
// Validate the input data with Zod
const { email, password } = data;
await signIn("credentials", {
email,
password,
redirect: false,
});
return { status: 200, message: "Successfully logged in" };
} catch (error) {
if (error instanceof z.ZodError) {
return {
status: 400,
message: error.errors.map((e) => e.message).join(", "),
};
}
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return { status: 500, message: "Invalid credentials" };
default:
return { status: 500, message: "Something went wrong" };
}
}
console.error(error);
return { status: 500, message: "An unexpected error occurred" };
}
}
B. Add the Credentials Provider to NextAuth
With our backend logic ready, we now wire up the Credentials Provider in NextAuth. This enables users to authenticate using an email and password. Update your auth.config.js file to include the Credentials:
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { getUserByEmail } from "@/utils/users";
export default {
providers: [
Credentials({
async authorize(credentials) {
const { email, password } = credentials;
const user = await getUserByEmail(email);
if (!user || !user.password) return null;
const isPasswordMatch = await bcrypt.compare(password, user.password);
if (isPasswordMatch) return user;
return null;
},
}),
],
};
C. Integrate with the Login Form UI using Zod & React Hook Form
Now that our server-side logic and NextAuth setup are ready, it’s time to build the Login UI. We'll use:
- Zod for schema validation
- React Hook Form for managing form state and handling submission
Here is the complete login form:
"use client";
import React, { useState } from "react";
import { Form } from "@/components/ui/form";
import CustomFormField from "@/components/Form/CustomFormField";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import GoogleButton from "@/components/Buttons/GoogleButton.jsx";
import GithubButton from "@/components/Buttons/GithubButton";
import Separator from "@/components/Common/Separator";
import { Button } from "../ui/button";
import Link from "next/link";
import { loginSchema } from "@/schema/validationSchema";
import { useRouter, useSearchParams } from "next/navigation";
import { login } from "@/lib/actions/authAction";
import FormError from "@/components/Form/FormError";
// login schema
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string(),
});
const LoginForm = () => {
const form = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
const searchParams = useSearchParams();
const urlError =
searchParams.get("error") === "OAuthAccountNotLinked"
? "This email is already associated with a different sign-in method."
: "";
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const onSubmit = async (values) => {
setLoading(true);
setError("");
try {
const response = await login(values);
if (response && response?.status !== 200) {
setError(response?.message);
}
router.push("/dashboard");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="grid w-full items-center gap-4">
<div className="flex items-center gap-3.5">
<GoogleButton text={"Login with Google"} />
<GithubButton text="Login with Github" />
</div>
<Separator />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col space-y-4">
<CustomFormField
control={form.control}
name="email"
label="Email"
placeholder="Email"
type="email"
/>
<CustomFormField
control={form.control}
name="password"
label="Password"
placeholder="password"
type="password"
/>
</div>
<FormError message={error || urlError} />
<Button variant="primary" className="mt-5 w-full" disabled={loading}>
{loading ? "Signing you in..." : "Sign in to your account"}
</Button>
</form>
</Form>
<p className="text-sm font-light text-muted-foreground">
Don't have an account?
<Link
href="/register"
className="font-medium text-blue-600 hover:underline ml-1"
>
Signup here
</Link>
</p>
</div>
);
};
export default LoginForm;
Testing the Auth Flow
Run the app:
npm run dev
- Go to http://localhost:3000/login.
- Choose a provider (Google or GitHub or Credentials).
- After signing in, you’ll be redirected to the /dashboard route.
- Access to /dashboard or any protected route without login should redirect back to /login.
Live preview: https://wiza-automation.vercel.app
Conclusion
By following this step-by-step guide, you now have a fully functioning authentication system in your Next.js 15 app using:
- NextAuth v5 for session & social login
- Prisma ORM for managing your MongoDB data
- MongoDB Atlas as the cloud database
- JWT-based session strategy for flexibility
- Middleware to protect your routes
This setup is perfect for any web project that needs secure login with providers like Google or GitHub and Credentials.
Have Questions or Suggestions?
Drop a comment or reach out on [email protected] — I’d love to help or hear your thoughts.
Social Links:
Youtube | GitHub | LinkedIn
Thank you, Muhib.