In this article, you will learn how to integrate custom login and signup pages with NextAuth.js in the new Next.js 13 app directory. Am going to assume that you have already set up NextAuth in your project and that you are only looking for a way to integrate your custom login and signup pages.

If you’re new to NextAuth and want to see how it can be used in the new Next.js 13 app directory, I recommend checking out the post titled “Setup and Use NextAuth.js in Next.js 13 App Directory“.

Before we dive into the integration process, let me give you a quick overview of how simple it is to integrate custom login and signup pages in NextAuth. To tell NextAuth to use your custom login page, you only need to provide the absolute path to the login page as the value of the signIn field within the pages object in the NextAuth configuration options.

Regarding account registration, NextAuth does not provide a built-in strategy for handling it. Instead, it leaves this aspect of authentication for us to implement. This is because for some strategies like magic links, email logins, or OAuth, there is no need for a registration phase. For example, with Google OAuth, users sign up by clicking the “login with Google” button, and if they don’t have an account, it’s created in real-time.

However, with credential authentication, you’ll need to create the user in the first place, which typically happens on a registration page. In this article, I’ll show you how to create custom login and signup pages and link them to NextAuth.

It’s worth mentioning that I am using a specific pull request recommended by the NextAuth team that is compatible with the new Next.js app directory. To install it, add "next-auth": "0.0.0-pr.6777.c5550344" to your dependencies, or you can install it via your package manager.

More practice:

Next.js 13 - Use Custom Login and SignUp Pages for NextAuth.js

Run the NextAuth Project on your Computer

To view a demo of the project, follow these simple steps:

  1. Download or clone the project from its GitHub repository at https://github.com/wpcodevo/nextauth-nextjs13-prisma and open it in your favourite IDE or text editor.
  2. Open your IDE’s integrated terminal and change the Git branch by running git checkout nextauth-nextjs13-signin-signup. If you intend to use the main branch, you’ll need to add your GitHub and Google OAuth client IDs and secrets to the .env file. This is necessary to enable authentication with GitHub and Google OAuth along with the credential provider.
  3. Install the project’s dependencies by running the command yarn or yarn install.
  4. Start a PostgreSQL server in a Docker container with the command docker-compose up -d.
  5. Apply the Prisma migrations to the Postgres database by running yarn prisma db push. This command will also generate the Prisma Client in the node modules folder.
  6. Start the Next.js development server by running yarn dev.
  7. Finally, test the authentication features by visiting the app at http://localhost:3000/ and playing around with the different options.

Test the NextAuth Project

Now that the app is open in your browser, you can proceed to register an account, sign in, access a protected page, and log out when you’re done.

Register an Account

To create a new account, navigate to the registration page and fill in your name, email, and password. After providing the necessary information, click the “Sign Up” button to submit the form data to the Next.js API route handler responsible for processing new user registrations. If your registration is successful, you will be redirected to the login page.

The Account Registration Page of the Next.js and NextAuth.js Project

Log into your Account

To sign in to your account, go to the login page and enter the email and password you used when creating the account. Then, click the “Sign In” button to submit your credentials to the Next.js API. If your credentials are authenticated successfully, you will be redirected to the profile page where you can view your account information.

The Login Page of the NextAuth.js Project

Access a Protected Page

Please note that the Profile page is protected and can only be accessed with valid NextAuth cookies. You can view the cookies sent by NextAuth by inspecting the page and checking the cookies section in your Dev tools.

On the Profile page, you’ll be able to view your profile picture, name, and email address. To sign out, simply click on the “Logout” button located in the navigation bar.

The Protected Page of the Next.js 13 and NextAuth Project

Configure the NextAuth API Endpoint

With that out of the way, we can proceed with configuring NextAuth to use our custom login page for authentication. To achieve this, we will define a ‘pages‘ object within the NextAuthOptions object. Then, we set the signIn property to the absolute path of our login page. This way, NextAuth will use our custom login page instead of the default one.

Typically, the NextAuth options are defined and exported in the auth/[...nextauth]/route.ts file. However, some users have encountered export errors while running the project. To solve this issue, we will create a separate file that only contains the NextAuth options.

To begin, create a new folder named lib within the src directory. Once the lib folder is created, navigate inside it and create a file named auth.ts. Then, add the provided code to the auth.ts file.

src/lib/auth.ts


import { prisma } from "@/lib/prisma";
import { compare } from "bcryptjs";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

export const authOptions: NextAuthOptions = {
  pages: {
    signIn: "/login",
  },
  session: {
    strategy: "jwt",
  },
  providers: [
    CredentialsProvider({
      name: "Sign in",
      credentials: {
        email: {
          label: "Email",
          type: "email",
          placeholder: "example@example.com",
        },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials.password) {
          return null;
        }

        const user = await prisma.user.findUnique({
          where: {
            email: credentials.email,
          },
        });

        if (!user || !(await compare(credentials.password, user.password))) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          randomKey: "Hey cool",
        };
      },
    }),
  ],
  callbacks: {
    session: ({ session, token }) => {
      return {
        ...session,
        user: {
          ...session.user,
          id: token.id,
          randomKey: token.randomKey,
        },
      };
    },
    jwt: ({ token, user }) => {
      if (user) {
        const u = user as unknown as any;
        return {
          ...token,
          id: u.id,
          randomKey: u.randomKey,
        };
      }
      return token;
    },
  },
};

Now that we have moved the NextAuth configuration to the src/lib/auth.ts file to avoid export errors, we need to define and export the GET and POST API handlers NextAuth will use for authentication using the NextAuth() method.

To get started, open your api/auth/[...nextauth]/route.ts file and add the following code:

src/app/api/auth/[…nextauth]/route.ts


import { authOptions } from "@/lib/auth";
import NextAuth from "next-auth";

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

Create the Custom Login Page

With NextAuth configured to use our custom login page, we’re ready to create the login component. However, since all components inside the app directory are React Server Components (RSC) by default, certain browser APIs and React hooks, including those needed for form validation, are not available.

To work around this limitation, we’ll need to render the form component on the client side by adding the "use client"; directive at the top of the file.

Create the Login Form

Before we dive into creating the form, it’s important to consider a few key points. First, we need to check if the callbackUrl parameter has been added to the URL by NextAuth. If it’s present, we need to extract it and store it in a variable. This allows us to dynamically redirect the user to the appropriate page after successful authentication, rather than hard-coding a specific URL.

Next, we’ll create a form with email and password inputs because NextAuth’s credential provider expects these fields for authentication.

To handle form submission, we’ll create an onSubmit() function that is triggered when the user submits the form. Within this function, we’ll use the signIn() method provided by NextAuth, passing in the email and password inputs as arguments. Additionally, we’ll provide the signIn() method with the callbackUrl, which is the URL that NextAuth will redirect the user to after successful authentication.

It’s important to note that by default, NextAuth will reload the browser if authentication fails, which will prevent us from displaying an error message to the user. To work around this, we can add redirect: false to the signIn() method, which means we’ll need to handle the redirect ourselves. After the promise has been resolved, we will check if there was an error. If there was, we will display an error message to the user. Otherwise, we will redirect them to the callback URL.

To create the login form, navigate to the app directory and create a new ‘login‘ folder. Within the ‘login‘ folder, create a form.tsx file and add the following code.

src/app/login/form.tsx


"use client";

import { signIn } from "next-auth/react";
import { useSearchParams, useRouter } from "next/navigation";
import { ChangeEvent, useState } from "react";

export const LoginForm = () => {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [formValues, setFormValues] = useState({
    email: "",
    password: "",
  });
  const [error, setError] = useState("");

  const searchParams = useSearchParams();
  const callbackUrl = searchParams.get("callbackUrl") || "/profile";

  const onSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      setLoading(true);
      setFormValues({ email: "", password: "" });

      const res = await signIn("credentials", {
        redirect: false,
        email: formValues.email,
        password: formValues.password,
        callbackUrl,
      });

      setLoading(false);

      console.log(res);
      if (!res?.error) {
        router.push(callbackUrl);
      } else {
        setError("invalid email or password");
      }
    } catch (error: any) {
      setLoading(false);
      setError(error);
    }
  };

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;
    setFormValues({ ...formValues, [name]: value });
  };

  const input_style =
    "form-control block w-full px-4 py-5 text-sm font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none";

  return (
    <form onSubmit={onSubmit}>
      {error && (
        <p className="text-center bg-red-300 py-4 mb-6 rounded">{error}</p>
      )}
      <div className="mb-6">
        <input
          required
          type="email"
          name="email"
          value={formValues.email}
          onChange={handleChange}
          placeholder="Email address"
          className={`${input_style}`}
        />
      </div>
      <div className="mb-6">
        <input
          required
          type="password"
          name="password"
          value={formValues.password}
          onChange={handleChange}
          placeholder="Password"
          className={`${input_style}`}
        />
      </div>
      <button
        type="submit"
        style={{ backgroundColor: `${loading ? "#ccc" : "#3446eb"}` }}
        className="inline-block px-7 py-4 bg-blue-600 text-white font-medium text-sm leading-snug uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out w-full"
        disabled={loading}
      >
        {loading ? "loading..." : "Sign In"}
      </button>

      <div className="flex items-center my-4 before:flex-1 before:border-t before:border-gray-300 before:mt-0.5 after:flex-1 after:border-t after:border-gray-300 after:mt-0.5">
        <p className="text-center font-semibold mx-4 mb-0">OR</p>
      </div>

      <a
        className="px-7 py-2 text-white font-medium text-sm leading-snug uppercase rounded shadow-md hover:shadow-lg focus:shadow-lg focus:outline-none focus:ring-0 active:shadow-lg transition duration-150 ease-in-out w-full flex justify-center items-center mb-3"
        style={{ backgroundColor: "#3b5998" }}
        onClick={() => alert("Not implemented yet")}
        role="button"
      >
        <img
          className="pr-2"
          src="/images/google.svg"
          alt=""
          style={{ height: "2rem" }}
        />
        Continue with Google
      </a>
      <a
        className="px-7 py-2 text-white font-medium text-sm leading-snug uppercase rounded shadow-md hover:shadow-lg focus:shadow-lg focus:outline-none focus:ring-0 active:shadow-lg transition duration-150 ease-in-out w-full flex justify-center items-center"
        style={{ backgroundColor: "#55acee" }}
        onClick={() => alert("Not implemented yet")}
        role="button"
      >
        <img
          className="pr-2"
          src="/images/github.svg"
          alt=""
          style={{ height: "2.2rem" }}
        />
        Continue with GitHub
      </a>
    </form>
  );
};

Create the Login Page

Our next step is to create a page component that will render the login form we just created. Since we’ve marked the form as a client-side component, we won’t run into any issues when we use it in the page component, which is a React Server Component.

To do this, navigate to the src/app/login directory and create a new file named page.tsx. Then, add the following code to the file.

src/app/login/page.tsx


import { LoginForm } from "./form";
import Header from "@/components/header.component";

export default function LoginPage() {
  return (
    <>
      <Header />
      <section className="bg-ct-blue-600 min-h-screen pt-20">
        <div className="container mx-auto px-6 py-12 h-full flex justify-center items-center">
          <div className="md:w-8/12 lg:w-5/12 bg-white px-8 py-10">
            <LoginForm />
          </div>
        </div>
      </section>
    </>
  );
}

Create the Custom SignUp Page

Next, let’s focus on the account registration component. This component is similar to the login component, but it won’t include the GitHub and Google OAuth buttons. While some companies choose to display these options on both the signup and login pages, we won’t be doing that in our example.

Create the SignUp Form

We’ll now create a registration form, which will also be rendered in the browser. To achieve this, we’ll start by adding the 'use client'; directive at the top of the file. The form will have fields for name, email, and password.

To handle form submission, we’ll create an onSubmit function that will be called when the user submits the form. In this function, we’ll extract the values entered by the user and use the Fetch API to send a POST request to our API route handler, which we’ll create shortly.

To create the registration form, navigate to the app directory and create a new folder called ‘register‘. Then, create a file called form.tsx inside the ‘register‘ folder and add the following code to it.

src/app/register/form.tsx


"use client";

import { signIn } from "next-auth/react";
import { ChangeEvent, useState } from "react";

export const RegisterForm = () => {
  const [loading, setLoading] = useState(false);
  const [formValues, setFormValues] = useState({
    name: "",
    email: "",
    password: "",
  });
  const [error, setError] = useState("");

  const onSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setFormValues({ name: "", email: "", password: "" });

    try {
      const res = await fetch("/api/register", {
        method: "POST",
        body: JSON.stringify(formValues),
        headers: {
          "Content-Type": "application/json",
        },
      });

      setLoading(false);
      if (!res.ok) {
        setError((await res.json()).message);
        return;
      }

      signIn(undefined, { callbackUrl: "/" });
    } catch (error: any) {
      setLoading(false);
      setError(error);
    }
  };

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;
    setFormValues({ ...formValues, [name]: value });
  };

  const input_style =
    "form-control block w-full px-4 py-5 text-sm font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none";

  return (
    <form onSubmit={onSubmit}>
      {error && (
        <p className="text-center bg-red-300 py-4 mb-6 rounded">{error}</p>
      )}
      <div className="mb-6">
        <input
          required
          type="name"
          name="name"
          value={formValues.name}
          onChange={handleChange}
          placeholder="Name"
          className={`${input_style}`}
        />
      </div>
      <div className="mb-6">
        <input
          required
          type="email"
          name="email"
          value={formValues.email}
          onChange={handleChange}
          placeholder="Email address"
          className={`${input_style}`}
        />
      </div>
      <div className="mb-6">
        <input
          required
          type="password"
          name="password"
          value={formValues.password}
          onChange={handleChange}
          placeholder="Password"
          className={`${input_style}`}
        />
      </div>
      <button
        type="submit"
        style={{ backgroundColor: `${loading ? "#ccc" : "#3446eb"}` }}
        className="inline-block px-7 py-4 bg-blue-600 text-white font-medium text-sm leading-snug uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out w-full"
        disabled={loading}
      >
        {loading ? "loading..." : "Sign Up"}
      </button>
    </form>
  );
};

Create the Account Registration Page

Now we can create a page component to render the registration form we just created. To do this, navigate to the src/app/register directory and create a page.tsx file. Then, add the code below to the file:

src/app/register/page.tsx


import { RegisterForm } from "./form";
import Header from "@/components/header.component";

export default function RegisterPage() {
  return (
    <>
      <Header />
      <section className="bg-ct-blue-600 min-h-screen pt-20">
        <div className="container mx-auto px-6 py-12 h-full flex justify-center items-center">
          <div className="md:w-8/12 lg:w-5/12 bg-white px-8 py-10">
            <RegisterForm />
          </div>
        </div>
      </section>
    </>
  );
}

Create the API Handler for Account Registration

To process the registration form’s POST request, we need to create a Next.js API route handler. Since this is a demo project, we’ll keep things simple and just hash the password, save the user to the database, and return the newly registered user in a JSON response.

Head over to the src/app/api directory and create a new folder called ‘register‘. Inside this folder, create a ‘route.ts‘ file and add the following code:

src/app/api/register/route.ts


import { prisma } from "@/lib/prisma";
import { hash } from "bcryptjs";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  try {
    const { name, email, password } = (await req.json()) as {
      name: string;
      email: string;
      password: string;
    };
    const hashed_password = await hash(password, 12);

    const user = await prisma.user.create({
      data: {
        name,
        email: email.toLowerCase(),
        password: hashed_password,
      },
    });

    return NextResponse.json({
      user: {
        name: user.name,
        email: user.email,
      },
    });
  } catch (error: any) {
    return new NextResponse(
      JSON.stringify({
        status: "error",
        message: error.message,
      }),
      { status: 500 }
    );
  }
}

I used a <Header /> component in both the login and signup pages, but I didn’t provide the code for it in this article as our main focus was to demonstrate the integration of custom login and signup pages with NextAuth. However, you can find the Header component code in the project’s GitHub repository, along with the Tailwind CSS configurations used in the project.

Conclusion

That wraps up our tutorial on integrating custom login and signup pages with NextAuth. Throughout this article, we have demonstrated how to handle user authentication using NextAuth in the new Next.js 13 app directory. Moreover, we went one step further and implemented the code to register new users.

I hope you found this article helpful and informative. If you have any questions or feedback, please don’t hesitate to leave a comment below. Thank you for reading!