Step-by-Step Guide to Using NextAuth (Auth.js) with Next.js and Express.js Backend


NextAuth (Auth.js) is a powerful and flexible authentication library for Next.js. It simplifies the process of adding authentication to your Next.js projects by providing built-in support for many authentication providers, including Google, GitHub, and email-based login. However, in some cases, you might need a custom backend to manage your authentication. In this blog, we will explore how to implement NextAuth (v5) in a Next.js project with an Express.js backend.
Prerequisites
Before diving into the code, ensure you have the following:
Node.js installed (preferably version 19.x or higher)
Next.js project set up (You can create a new Next.js app using
npx create-next-app
if you don’t have one)Express.js backend (We will integrate this with the Next.js app)
We'll start by creating the necessary Next.js and Express.js setups and then integrate NextAuth for user authentication.
Step 1: Set Up Your Next.js With Auth.js
First, if you don't already have a Next.js project, create one by running:
yarn create next-app nextjs-nextauth
cd nextjs-nextauth
This will generate the basic files and folder structure for your project.
Installing Auth.js
Run this Command to install Authjs
npm install next-auth@beta
or
yarn add next-auth@beta
Setup Environment
The only environment variable that is mandatory is the AUTH_SECRET
.
npx auth secret
This will generate AUTH_SECRET
in your .env file, which will be used in ./auth.ts file.
Configure files
- Create a file named
auth.ts
(orauth.js
) in the root directory and add the following content, or your desired content.
// ./auth.ts
import axios from "axios";
import { API } from "./app/utils/constants";
import NextAuth, { CredentialsSignin } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
// Google provider
GoogleProvider({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
// Custom Credentials provider
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "email", type: "text" },
password: { label: "password", type: "password" },
},
authorize: async (credentials) => {
const { email, password } = credentials;
try {
const res = await axios.post(`${API}/auth/login`, {
email,
password,
});
if (res.data.success) {
return {
id: res.data.user.id,
email: res.data.user.email,
fullName: res.data.user.fullName,
userName: res.data.user.userName,
profilePic: res.data.user.profilePic,
accessToken: res.data.token,
};
} else {
return { error: res.data.message || "Invalid credentials" };
}
} catch (error: any) {
if (error?.response) {
throw new CredentialsSignin(
error?.response?.data?.message || "Something went wrong."
);
} else if (error?.request) {
throw new CredentialsSignin(
"Server not responding. Please try again later."
);
} else {
throw new CredentialsSignin(
"An error occurred during login. Please try again."
);
}
}
},
}),
],
session: {
strategy: "jwt",
maxAge: 60 * 24 * 60 * 60,
},
callbacks: {
// SignIn Callback
async signIn({ account, user }: { account: any, user: any }) {
if (account?.provider === "google") {
try {
const res = await axios.post(`${API}/auth/google`, {
email: user.email,
fullName: user.name,
image: user.image,
id: user.id,
});
if (res.data && res.data.user) {
user.id = res.data.user.id;
// @ts-ignore
user.fullName = res.data.user.fullName;
// @ts-ignore
user.userName = res.data.user.userName;
user.profilePic = res.data.user.profilePic;
user.accessToken = res.data.token;
}
} catch (error) {
console.error(
"Error fetching/updating user data from Google login:",
error
);
return false; // Prevent sign-in if the API call fails
}
}
return true;
},
async jwt({ token, user }: { token: any, user: any }) {
if (user) {
token.id = user.id;
token.email = user.email;
token.fullName = user.fullName;
token.userName = user.userName;
token.profilePic = user.profilePic;
token.accessToken = user.accessToken;
}
return token;
},
async session({ session, token }) {
// Add token info to session
session.user.id = token.id;
session.user.email = token.email;
session.user.fullName = token.fullName;
session.user.userName = token.userName;
session.user.profilePic = token.profilePic;
session.user.accessToken = token.accessToken;
return session;
},
},
pages: {
signIn: "/login",
},
secret: process.env.AUTH_SECRET,
});
Add a Route Handler under
/app/api/auth/[...nextauth]/route.ts
// app/api/auth/[...nextauth]/route.ts import { handlers } from "@/auth" export const { GET, POST } = handlers
You can add middleware (to check if the user is logged in or not) at root directory
./middleware.ts
// ./middleware.ts
import { auth } from "@/auth";
import { DEFAULT_REDIRECT_PATH, DEFAULT_RESTRICTED_REDIRECT_PATH, RESTRICTED_PATHS } from "./app/utils/constants";
export default auth((req) => {
if (!req.auth && !RESTRICTED_PATHS.includes(req.nextUrl.pathname)) {
const newUrl = new URL(DEFAULT_RESTRICTED_REDIRECT_PATH, req.nextUrl.origin);
return Response.redirect(newUrl);
}
if (req.auth && RESTRICTED_PATHS.includes(req.nextUrl.pathname)) {
const newUrl = new URL(DEFAULT_REDIRECT_PATH, req.nextUrl.origin);
return Response.redirect(newUrl);
}
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Now that we have set up all the necessary Next.js files for Auth.js, we will proceed with setting up our backend.
Step 2: Set Up Your Express.js Backend
Initialize a new project:
First, create a new directory and initialize a new Node.js project.
mkdir express-app
cd express-app
npm init -y
Install Dependencies:
Install the required packages cors
, jsonwebtoken
, nodemon
, and tsx
.
npm install express cors jsonwebtoken nodemon
npm install --save-dev typescript @types/node @types/express @types/cors @types/jsonwebtoken tsx
Set up TypeScript Configuration (tsconfig.json
):
Create a tsconfig.json
file to configure TypeScript.
npx tsc --init
This will generate a tsconfig.json
file. You can adjust it as follows:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "nodenext",
"target": "ES2020",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"rootDir": "src",
"outDir": "dist",
"allowJs": true,
"declaration": true
},
"include": ["src/**/*"]
}
Create the Server (src/index.ts
):
Create a basic Express server in src/index.ts
:
import express, { Request, Response } from 'express';
import cors from 'cors';
import authRouter from "./routes/auth.js"
import connectDb from "./helpers/connectDb.js";
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// Connect to Mongodb
connectDb(MONGO_URI);
// All routes of authRouter will be accessed by this
app.use("/api/auth", authRouter)
app.get("/", (_, res) => {
res.send("Hello World!");
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Update package.json
to Use tsx
:
In your package.json
, add a start script that uses tsx
to run the server:
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon --exec tsx src/index.ts",
"build": "tsc",
"clean": "rm -rf ./dist && rm -rf tsconfig.tsbuildinfo"
},
Define Routes in ./routes/auth.ts
The auth.ts
file will contain the login and register routes for handling user authentication. We'll set up two POST routes: one for logging in and another for registering users.
import express from "express"
import { login, registerUser } from "../controllers/auth.js"
import upload from "../middlewares/multer.js"
const router = express.Router()
router.post(`/login`, login)
router.post(`/register`, upload.single("profilePic"), registerUser)
export default router
Define Controllers in ./controllers/auth.ts
In this step, we'll implement the controller functions for login
and registerUser
. These will contain the main logic for authenticating users and handling the registration process.
export const login = asyncHandler(async (req: Request, res: Response) => {
const { email, password } = req.body;
// Check if email and password are provided
if (!email || !password) {
throw new CustomError("Email and password are required", 400);
}
// Check if email exists in the database
const user = await User.findOne({ email });
if (!user) {
throw new CustomError("Invalid email or password", 401);
}
if (user.password) {
const isPasswordMatch = await bcrypt.compare(password, user?.password);
if (!isPasswordMatch) {
throw new CustomError("Invalid email or password", 401);
}
const data = {
id: user._id.toString(),
fullName: user.fullName,
email: user.email,
profilePic: user.profilePic,
userName: user.userName,
};
const token = generateToken(data);
return res
.status(200)
.json({ message: "Login successful", token, user:data, success: true });
} else {
throw new CustomError("This account is linked with Google, please log in using Google.", 401);
}
});
export const registerUser = asyncHandler(
async (req: Request, res: Response) => {
const { fullName, userName, email, password } = req.body;
if (!req.file) {
throw new CustomError("Profile image is required", 400);
}
if (!fullName || !userName || !email || !password) {
throw new CustomError("All fields are required", 400);
}
await checkUserExists(email, userName);
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new CustomError("Email already exists", 400);
}
const hashedPassword = await bcrypt.hash(password, 10);
const profilePic = getUniqueMediaName(req.file.originalname);
const fullPath = `profilePics/${profilePic}`;
// Create a new user
const user = new User({
fullName,
userName,
email,
password: hashedPassword,
profilePic: `${CLOUDFRONT_URL}${fullPath}`,
});
await Promise.all([
uploadToS3(BUCKET_NAME, fullPath, req.file.buffer, req.file.mimetype),
user.save(),
]);
return res.status(200).json({
message: "User registered successfully",
success: true,
});
}
);
Configuring JWT at ./utils/jwt.ts
import jwt from 'jsonwebtoken';
import { JWT_SECRET_KEY } from './envConfig.js'
import { User } from '../types/types.js';
export const generateToken = (payload: User): string => {
return jwt.sign(payload, JWT_SECRET_KEY, { expiresIn: "65d" });
};
// Function to verify a JWT token
export const verifyToken = (token: string): User | null => {
try {
const decoded = jwt.verify(token, JWT_SECRET_KEY) as User;
return decoded;
} catch (error) {
return null;
}
};
Adding Middleware for Authentication at ./middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import asyncHandler from "./tryCatch.js";
import { CustomError } from "./errors/CustomError.js";
import { verifyToken } from "../utils/jwt.js";
export const verifyUserToken = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
throw new CustomError("Unauthorized: No token provided", 401);
}
const decoded = verifyToken(token);
if (!decoded) {
throw new CustomError("Unauthorized: Invalid or expired token", 401);
}
req.user = decoded;
next();
}
);
Now is backend set up, it's time to connect it to the frontend and bring everything together.
Step 3: Connecting Backend And Frontend
First, we'll create a Login form component at @/app/(auth)/_components/login-form.tsx
.
"use client";
import React, { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { motion } from "motion/react";
import { CiLogin } from "react-icons/ci";
import { handleCredentialLogin } from "@/actions/auth";
import { toast } from "react-toastify";
import { errorHandler } from "@/app/utils/helper";
import Link from "next/link";
const loginSchema = z.object({
email: z
.string()
.email({ message: "Invalid email address" })
.regex(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, {
message: "Invalid email address format",
}),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters" }),
});
export type FormValues = z.infer<typeof loginSchema>;
const Login = () => {
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "example@gmail.com",
password: "ayushdixit23",
},
});
const onSubmit = async (data: FormValues) => {
try {
// data = {email,password}
setLoading(true);
const res = await handleCredentialLogin(data);
if (res?.error) {
toast.error(res?.error.split(".")[0]);
return;
}
toast.success("Login Successfull!");
window.location.reload()
} catch (error) {
errorHandler(error)
} finally {
setLoading(false);
}
};
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4 p-1"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">Email</FormLabel>
<FormControl>
<Input
placeholder="john@example.com"
{...field}
className="mt-1 bg-white/50 dark:bg-transparent border border-blue-100 outline-none rounded-xl h-12 pl-4"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">Password</FormLabel>
<FormControl>
<Input
placeholder="*******"
{...field}
className="mt-1 bg-white/50 border dark:bg-transparent border-blue-100 outline-none rounded-xl h-12 pl-4"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.95 }}
disabled={ loading}
className={`w-full mt-2 flex justify-center text-white items-center gap-2 h-12 gradient-bg gradient-bg:hover rounded-xl font-medium text-lg ${loading || googleLoading ? "opacity-70 cursor-not-allowed" : ""
}`}
type="submit"
>
{loading ? "Loading..." : "Log In"}
{!loading && <CiLogin />}
</motion.button>
</form>
</Form>
<p className="text-sm font-light mt-6 dark:text-white text-center">
Don’t have an account yet?{" "}
<Link
href="/signup"
className="font-medium text-primary-600 hover:underline dark:text-primary-500"
>
Sign Up
</Link>
</p>
</>
);
};
export default Login;
Now, we will create a server action file under ./actions/auth.ts
.
import { signIn, signOut } from "@/auth";
import { AuthError } from "next-auth";
import { FormValues } from "@/app/(auth)/_components/login-form";
export const handleCredentialLogin = async (data: FormValues) => {
try {
const result = await signIn("credentials", {
...data,
redirect: false,
});
return result;
} catch (error: unknown) {
console.error(error);
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return { error: error.message || "Invalid credentials." };
default:
return { error: "Something went wrong." };
}
}
throw error;
}
};
Explanation
When the user clicks the login button, the onSubmit
function is triggered, which calls the handleCredentialLogin
function. This function is responsible for handling the login process. It utilizes the signIn
function from NextAuth to authenticate the user with the provided credentials. The signIn
function sends a request to the NextAuth API with the credentials
provider and the credentials data, along with the redirect: false
option. This prevents NextAuth from automatically redirecting after the authentication attempt, giving you control over the next steps (such as handling errors or redirecting manually).
If authentication is successful, signIn
returns the result, typically including session data. If an error occurs, such as invalid credentials, the error is caught and handled appropriately (e.g., showing an error message). In your flow, the handleCredentialLogin
function is used to trigger the authentication process, but it doesn't explicitly call an authorize
function. The actual authorization step is handled internally by NextAuth itself, with the signIn
function managing the authorization and session creation process.
Getting Response From Backend
We will receive the response from the backend in the following format
{
"message": "Login successful",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYyNzQzODc4LTYzYjktNDJkYi05YjAxLTQ1MmU3ZDNkMzc5MCIsImZ1bGxOYW1lIjoiSm9obiBEb2UifQ.q0MjJ3jX6ssBXaFg2oO2t5NhUEmwE8RIEJcft0URvAY",
"user": {
"id": "62743878-63b9-42db-9b01-452e7d3d3790",
"fullName": "John Doe",
"email": "johndoe@example.com",
"profilePic": "https://example.com/profile-pic.jpg",
"userName": "johndoe123"
},
"success": true
}
Now, the user
in the response object, which contains our userData
, will be stored in the session for NextAuth. To use this session, we need to wrap our application with a SessionProvider
, allowing us to access the session values.
Creating a NextAuthProvider
in ./components/providers/session-provider.tsx
.
"use client";
import { SessionProvider } from "next-auth/react";
import { Session } from "next-auth";
export interface AuthProviderProps {
children: React.ReactNode;
session?: Session | null;
}
function NextAuthProvider({ children, session }: Readonly<AuthProviderProps>) {
return <SessionProvider session={session}>{children}</SessionProvider>;
}
export default NextAuthProvider;
Now wraps this component in ./app/layout.tsx
import "./globals.css";
import NextAuthProvider from "@/components/providers/session-provider";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={` antialiased`}>
<NextAuthProvider>
{children}
</NextAuthProvider>
</body>
</html>
);
}
Now , we can use useSession
hook , to get our sessionData.
import { useSession } from "next-auth/react";
const Page = () => {
const { data: session, status } = useSession();
//console.log(session)
//{
// user: {
// id: "62743878-63b9-42db-9b01-452e7d3d3790",
// email: "johndoe@example.com",
// fullName: "John Doe",
// userName: "johndoe123",
// profilePic: "https://example.com/profile-pic.jpg",
// accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYyNzQzODc4LTYzYjktNDJkYi05YjAxLTQ1MmU3ZDNkMzc5MCIsImZ1bGxOYW1lIjoiSm9obiBEb2UifQ.q0MjJ3jX6ssBXaFg2oO2t5NhUEmwE8RIEJcft0URvAY"
// },
//expires: "2025-03-07T12:00:00Z"
//}
if (status === "loading") {
return <div>Loading...</div>;
}
if (!session) {
return <div>Please log in.</div>;
}
return (
<div>
<h1>Welcome, {session.user.fullName}</h1>
<img src={session.user.profilePic} alt="Profile" />
</div>
);
};
Handling Authentication (Between client and server)
Now that we have configured Auth.js
for Next.js and connected the client and server, the question arises: how do we access data from the backend with authentication? Since NextAuth creates its own JWT token, which cannot be used directly in our backend for verification, we will solve this issue by sending the session.user.accessToken
that is stored in the session data. By sending this token, we can verify and authenticate ourselves to access the data.
import axios from 'axios';
import { useSession } from "next-auth/react";
import { API } from "@/app/utils/constants";
const fetchDataWithAuth = async () => {
const { data: session, status } = useSession();
try {
const res = await axios.get('${API}/getData', {
headers: {
// Send Authorization header with Bearer token for authentication
Authorization: `Bearer ${session?.user.accessToken}`,
},
});
console.log('Response Data:', res.data);
} catch (error) {
console.error('Error fetching data:', error);
}
};
That’s how we would authenticate users.
Conclusion
In this blog, we’ve explored how to integrate Auth.js with Next.js to handle authentication in a modern web application. From setting up the authentication provider to managing sessions and tokens, we've covered the essential steps to ensure secure access to your backend.
By using Auth.js, you gain flexibility in managing user authentication and can easily implement features like JWT-based authorization, session handling, and seamless integration with both client and server-side components. As we demonstrated, sending the session’s access token for backend authentication helps bridge the gap between the client and server, ensuring secure and efficient data access.
Auth.js v5 makes authentication easier to manage and more secure with minimal configuration, allowing you to focus on building your app’s features. Whether you’re building a simple blog or a complex web app, Auth.js provides the tools you need to implement a robust authentication system.
As you continue to scale your application, remember that keeping your authentication flow secure and user-friendly should always be a top priority. With Auth.js and Next.js, you’re on the right path to creating secure and maintainable web applications.
Subscribe to my newsletter
Read articles from Ayush Dixit directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Ayush Dixit
Ayush Dixit
I'm a passionate MERN stack developer who loves building websites and apps. I enjoy solving problems and bringing ideas to life through code. I believe in learning something new every day to improve my skills and keep up with the latest technologies. I’m always excited to work with other developers, share knowledge, and contribute to open-source projects. Let’s connect and create something great together!