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

Ayush DixitAyush Dixit
13 min read

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:

  1. Node.js installed (preferably version 19.x or higher)

  2. Next.js project set up (You can create a new Next.js app using npx create-next-app if you don’t have one)

  3. 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

  1. Create a file named auth.ts (or auth.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,
});
  1. 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
    
  2. 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.

0
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!