Using NextAuth v5, Prisma, Zod and Shadcn with Next.js 14 for building an authentication app

By following this tutorial, you'll create a modern authentication application with Next.js 14 server actions, NextAuth.js v5, Zod for form validation, and Prisma for the database. This stack provides a powerful combination of tools for building secure and scalable web applications with robust authentication features.

We'll be using:

  • Next.js 14: Next.js is a React framework that enables server-side rendering, static site generation, and more. Version 14 brings various improvements and features.

  • NextAuth.js v5: NextAuth.js is a complete authentication solution for Next.js applications. Version 5 introduces enhancements and new features.

  • Shadcn for UI components: Shadcn provides UI components that you can use to quickly build user interfaces in your Next.js application. It offers a range of customizable components.

  • Zod for schema validation: Zod is a TypeScript-first schema declaration and validation library. It helps ensure data consistency and type safety in your application.

  • Prisma: Prisma is an ORM (Object-Relational Mapping) tool for Node.js and TypeScript. It simplifies database access and management, providing a type-safe way to interact with your database.

In this tutorial, we'll guide you through the process of creating a robust authentication application using Next.js 14, NextAuth.js v5, Zod for form validation, and Prisma for the database.

Here are the steps we'll be following throughout this tutorial:

Setting Up Your Development Environment Ensure you have Node.js 18.17 or later installed on your machine, along with npm or yarn, which comes bundled with Node.js.

Creating a Next.js Project Start by initializing a new Next.js project using the Next.js CLI. This CLI will set up a basic Next.js project structure for you to build upon.

Installing Dependencies Navigate to your project directory and install the necessary dependencies using npm or yarn. This includes NextAuth.js for authentication, Prisma for database management, and Zod for form validation.

Configuring NextAuth.js NextAuth.js provides a flexible authentication library for Next.js applications. Configure authentication providers, callbacks, and options in the [...nextauth].js file located in your project root.

Setting Up Zod for Form Validation Implement form validation using Zod, a powerful schema validation library. Define schemas to validate user input for login, registration, password reset, and other authentication-related forms.

Integrating Prisma for the Database Prisma offers a type-safe database access layer that simplifies database interactions. Set up Prisma to connect to your chosen database (e.g., PostgreSQL, MySQL) and define database models to represent your application data.

Building Authentication UI Components Design and implement UI components for authentication features such as login forms, and registration forms, etc.

Implementing Server Actions with Next.js 14 Leverage Next.js 14 server actions to handle authentication-related logic on the server-side. Use server-side functions to perform actions like user authentication, and user sign out.

Now, let's dive into each aspect of building your authentication app and bring your project to life!

The prerequisites

Let's get started by checking the prerequisites. According to the official website we need to have Node.js 18.17 or later installed on our development machine.

Create a Next.js 14 project

Now, let's create our Next.js 14 application. Open a terminal and run the following command to create a Next.js 14 project:

npx create-next-app@latest authjs-tutorial

Answer the questions as follows:

[email protected]
Ok to proceed? (y) y
✔ Would you like to use TypeScript? …  Yes
✔ Would you like to use ESLint? …  Yes
✔ Would you like to use Tailwind CSS? …  Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) …  Yes
✔ Would you like to customize the default import alias (@/*)? … No

After the prompts, create-next-app will create a folder with your project name and install the required dependencies:

Using npm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next

If you're new to Next.js, see the project structure docs for an overview of all the possible files and folders in your application.

Open your project in Visual Studio Code as follows

cd authjs-tutorial
code .

Installing Shadcn

Next, we'll be using Shadcn for UI components, so go ahead and run the following command inside your Next.js 14 project:

npx shadcn-ui@latest init

Answer the questions as follows:

✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Slate
✔ Would you like to use CSS variables for colors? … yes

After installing Shadcn, we can use Shadcn components in our project.

You can serve your Next.js app using the following command:

npm run dev

It will be available from http://localhost:3000.

Adding a Shadcn button

Open the app/page.tsx file and clear the existing markup as follows:

export default function Home() {
  return <p> Hello Auth.js </p>;
}

Now, let's see how to add a button component to our project using the following command:

npx shadcn-ui@latest add button

This will add components/ui/button.tsx inside our project and we can use the button as follows:

import { Button } from "@/components/ui/button";

export default function Home() {
  return <Button>Login with Email</Button>;
}

You should see a button with "Login with Email" text when your visit your browser. This means Tailwind and Shadcn are correctly working. Let's continue building our authentication demo.

Building the home page with a login button

First, open the app/globals.css file and add the following CSS code:

html,
body,
:root {
  height: 100%;
}

Create a components/login-button.tsx file and update as follows:

"use client";
import { useRouter } from "next/navigation";

interface LoginButtonProps {
  children: React.ReactNode;
}

export const LoginButton = ({ children }: LoginButtonProps) => {
  const router = useRouter();

  const onClick = () => {
    router.push("/auth/login");
  };
  return (
    <span onClick={onClick} className="cursor-pointer">
      {children}
    </span>
  );
};

We're creating a reusable login button component in React using Next.js. This component is straightforward and clean. It makes use of the useRouter hook from Next.js to handle navigation.

We first import useRouter from "next/navigation", which is a hook provided by Next.js to handle routing within our applications.

We define an interface LoginButtonProps which specifies that the children prop should be of type React.ReactNode. The LoginButton component takes children as a prop and returns a <span> element. When this <span> is clicked, it triggers the onClick function.

Inside the onClick function, we call router.push("/auth/login") to navigate to the login page when the button is clicked.

Finally, we render the children inside the <span> element, allowing us to customize the content of the login button.

This component is well-structured and reusable, making it easy to integrate login functionality into different parts of our application.

Next, open the app/page.tsx file and update it as follows:

import { LoginButton } from "@/components/login-button";
import { Button } from "@/components/ui/button";

export default function Home() {
  return (
    <main className="flex h-full flex-col items-center justify-center bg-sky-500">
      <div className="space-y-6">
        <h1 className="text-6xl font-bold text-white drop-shadow-md">Auth</h1>
        <p className="text-white text-lg">Auth.js authentication demo</p>
        <div>
          <LoginButton>
            <Button size="lg" variant="secondary">
              Sign in
            </Button>
          </LoginButton>
        </div>
      </div>
    </main>
  );
}

We are using the LoginButton component in our Next.js application's homepage (Home component). We are using the Button component from "@/components/ui/button" for rendering the sign-in button inside the LoginButton.

We first import the LoginButton component from "@/components/login-button". We then import the Button component from "@/components/ui/button".

Inside the Home component's JSX, we are using a <main> element with a flex layout to center its children vertically and horizontally. The background color is set to bg-sky-500.

Inside the <main> element, we have a <div> with a class of space-y-6, which adds vertical spacing between its children.

We have a large title (<h1>) and a paragraph (<p>) describing the purpose of the authentication demo.

Inside another <div>, we are adding the LoginButton component. The Button component is passed as a child to LoginButton. This means the sign-in button rendered by Button will be placed inside the LoginButton component.

Overall, our homepage has a clean structure with a focus on the authentication demo. The use of components like LoginButton and Button promotes reusability and maintainability of our code.

If we go back to our browser and click on the "Sign in" button we'll be redirected to http://localhost:3000/auth/login which responds with a 404 page because it's not implemented yet!

Adding the login page and form

So, now we have a 404 page in our login path, let's fix that!

Inside tha app/ folder, create a new folder called auth/. Inside this new folder, create another folder called login/. Inside of it create an page.tsx file and add the following code:

import { LoginForm } from "@/components/login-form";

export default function Login() {
  return <LoginForm />;
}

This will throw an error since the LoginForm component doesn't exist yet!

Inside the auth/ folder, create a layout.tsx file and add the following code:

const AuthLayout = ({ children }: { children: React.ReactNode }) => {
  return (
    <div className="h-full flex items-center justify-center bg-sky-500">
      {children}
    </div>
  );
};

export default AuthLayout;

Now let's create our login form component. Inside the components folder, create a login-form.tsx file and add the following code:

export const LoginForm = () => {
  return <span>Login Page</span>;
};

We'll be using the Card component from Shadcn to build our login form so go ahead and install the Card component by running the following command:

npx shadcn-ui@latest add card
npx shadcn-ui@latest add input
npx shadcn-ui@latest add form

Adding the Zod schema for login

Next, create a login form schema which we are going to use inside our login form so create a schemas/ folder in the root of our application. Inside, simply create an index.ts file with the following code:

import * as z from "zod";

export const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1, { message: "Password is required" }),
});

Adding a login form

Next, go back to the login form in the components/login-form.tsx file and let's add a form. Start by adding the following imports:

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { LoginSchema } from "@/schemas";

import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

import Link from "next/link";

Since we are setting up a form for user authentication using Next.js, we need some UI components like Form, Input, Button, and Card.

We first import useForm from react-hook-form to handle form state and validation. We import zodResolver from @hookform/resolvers/zod to use Zod schema for form validation. We import z from zod for defining schemas.

We import LoginSchema from "@/schemas" which is our Zod schema for login form validation.

We import various UI components like Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Button, Input, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle from "@/components/ui".

We import Link from "next/link" for navigation.

We are setting up a form with validation using react-hook-form and Zod for schema-based validation. The UI components are organized into separate files for better modularity and reusability. The next/link is used for client-side routing.

Next; inside the LoginForm component create a form using the following code:

const form = useForm<z.infer<typeof LoginSchema>>({
  resolver: zodResolver(LoginSchema),
  defaultValues: {
    email: "",
    password: "",
  },
});

Next, return the following TSX markup:

<Card className="w-[400px]">
  <CardHeader>
    <CardTitle>Auth.js</CardTitle>
    <CardDescription>Welcome!</CardDescription>
  </CardHeader>
  <CardContent></CardContent>
  <CardFooter className="flex justify-between flex-col">
    <Link className="text-xs" href="/auth/register">
      Don't have an account
    </Link>
  </CardFooter>
</Card>

Inside the CardContent component, add the form as follows:

<Form {...form}>
  <form onSubmit={form.handleSubmit(() => {})} className="space-y-7">
    <div className="space-y-4">
      <FormField
        control={form.control}
        name="email"
        render={({ field }) => (
          <FormItem>
            <FormLabel>Email</FormLabel>
            <FormControl>
              <Input {...field} />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />
      <FormField
        control={form.control}
        name="password"
        render={({ field }) => (
          <FormItem>
            <FormLabel>Password</FormLabel>
            <FormControl>
              <Input {...field} placeholder="******" type="password" />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />
    </div>
    <Button type="submit" size="lg" className="w-full">
      Sign in
    </Button>
  </form>
</Form>

Next, we need to handle the submit of the form by adding the following function:

const onSubmit = (values: z.infer<typeof LoginSchema>) => {
  console.log(values);
};

Then change the form markup as follows:

<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-7">

Instead of the empty function we simply add our onSubmit function. For now, it simply logs an object containing the email and password in the console but we'll change it later to handle the form appropriately.

Handling the login form with server actions

Inside the root folder, create an actions/ folder. Inside, create a login.ts file and add the following code:

"use server";
export const login = (values: any) => {
  console.log(values);
};

That's it we have a server action! We'll update it later to log in the users. For now, let's modify our form component by calling this server action. Go back to the components/login-form.tsx file and add the following import:

import { login } from "@/actions/login";

Next, update the onSubmit function as follows:

const onSubmit = (values: z.infer<typeof LoginSchema>) => {
  login(values);
};

Now, if we submit the form, the email and password will be displayed in the terminal instead of the browser's console. Before updating this method to sign in users, let's create the register form.

Our app now has a basic structure for authentication with a home page containing a login button and a login page with a form. The form submission is currently handled by displaying the form values. Next steps would involve implementing actual authentication logic, such as validating credentials and redirecting authenticated users.

Before adding the Prisma data, let's make our server action asynchronous because in real situation that's going to be the case. Change the login action as follows:

"use server";
export const login = async (values: any) => {
  await new Promise((resolve) => {
    setTimeout(resolve, 3000);
  });
  console.log(values);
};

In our scenario, the server action waits for 3000 milliseconds before logging the values we sent. However, we notice that even while the server action is processing, users can still click the "sign in" button, triggering another server action and potentially leading to a poor user experience.

Improving the UI with startTransition hook

To solve this we can simply use useTransition hook to make the button disabled until the server action finishes processing. In the components/login-form.tsx file import the useTransition hook:

import { useTransition } from "react";

Then add the following code inside the LoginForm component:

const [isPending, startTransition] = useTransition();

useTransition is a React Hook provided by React's concurrent mode API. It returns an array with two elements: isPending and startTransition.

This variable represents the current state of the transition. It's a boolean value that indicates whether a transition is currently pending or not. When a transition is pending, it typically means that some asynchronous operation is in progress.

The function is used to start a transition. Transitions are a way to signal to React that a particular operation is starting, allowing React to prioritize updates accordingly. By wrapping asynchronous operations inside startTransition, React can optimize rendering and avoid blocking the UI thread unnecessarily.

Next, update the onSubmit method:

const onSubmit = (values: z.infer<typeof LoginSchema>) => {
  startTransition(async () => {
    await login(values);
  });
};

Next, update the sign in button of the form:

<Button disabled={isPending} type="submit" size="lg" className="w-full">
  Sign in
</Button>

Now if you try to submit the form, the button will be disabled for a short time until the form is processed, improving the overall user experience by preventing multiple submissions during processing.

Creating the register form

It's similar to how we implemented the login page.

Inside the app/auth/ folder create another folder called register/. Inside of it create an page.tsx file and add the following code:

import { RegisterForm } from "@/components/register-form";

export default function Register() {
  return <RegisterForm />;
}

Create a components/register-form.tsx file, and add the following imports:

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { RegisterSchema } from "@/schemas";

import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

import Link from "next/link";
import { register } from "@/actions/register";
import { useTransition } from "react";

Next, in the same file, add the following code:

import Link from "next/link";
import { register } from "@/actions/register";
import { useTransition } from "react";

export const RegisterForm = () => {
  const form = useForm<z.infer<typeof RegisterSchema>>({
    resolver: zodResolver(RegisterSchema),
    defaultValues: {
      email: "",
      password: "",
      name: "",
    },
  });

  const [isPending, startTransition] = useTransition();

  const onSubmit = (values: z.infer<typeof RegisterSchema>) => {
    startTransition(async () => {
      await register(values);
    });
  };

  return (
    <Card className="w-[400px]">
      <CardHeader>
        <CardTitle>Auth.js</CardTitle>
        <CardDescription>Create an account!</CardDescription>
      </CardHeader>
      <CardContent>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-7">
            <div className="space-y-4">
              <FormField
                control={form.control}
                name="name"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Name</FormLabel>
                    <FormControl>
                      <Input {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Email</FormLabel>
                    <FormControl>
                      <Input {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="password"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Password</FormLabel>
                    <FormControl>
                      <Input {...field} placeholder="******" type="password" />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </div>
            <Button
              disabled={isPending}
              type="submit"
              size="lg"
              className="w-full"
            >
              Sign up
            </Button>
          </form>
        </Form>
      </CardContent>
      <CardFooter className="flex justify-between flex-col">
        <Link className="text-xs" href="/auth/login">
          Already registered?
        </Link>
      </CardFooter>
    </Card>
  );
};

Next, create actions/register.ts file and add the following code:

"use server";
export const register = async (values: any) => {
  await new Promise((resolve) => {
    setTimeout(resolve, 3000);
  });
  console.log(values);
};

Prisma database and schema

Let's now integrate Prisma database with our login server action. In your terminal run these commands:

npm install @prisma/client @auth/prisma-adapter
npm install prisma --save-dev

Next, run the following command to initialize Prisma:

npx prisma init

After this command, Your Prisma schema is created at prisma/schema.prisma and a .env file is created with a DATABASE_URL variable that you need to change to point to your actual database connection string. You can go to https://neon.tech/ and create a free Postgres database.

If you open the prisma/schema.prisma file, you should find the following code:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

Add the following models:

model User {
  id            String          @id @default(cuid())
  name          String?
  email         String          @unique
  emailVerified DateTime?
  image         String?
  password      String?
  accounts      Account[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Account {
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@id([provider, providerAccountId])
}

Next, run the following commands:

npm exec prisma generate
npm exec prisma migrate dev

The first command generates Prisma Client, which is an auto-generated database client library specific to your database schema. Prisma Client provides type-safe database access and is used to perform database operations in your application code.

The second will generate migrations and apply them to make the database in sync with the Prisma schema. Now, if you look at your database tables you should find the User and Account tables.

Migrations are a way to keep your database schema in sync with your application's codebase. When you make changes to your database schema, you create a migration to apply those changes to the database. The dev command applies any pending migrations in development mode.

You can also run the following command to synchronize your database with your schema:

npm exec prisma db push

Next, create the lib/db.ts file and add the following code:

import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient();
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

This sets up a singleton pattern for the Prisma Client in our Next.js application. By implementing this pattern, you ensure that there's only one instance of the Prisma Client throughout your Next.js application, improving resource efficiency and preventing issues related to multiple database connections.

Encrypting passwords with bcyprt and checking for existing user with Prisma

In order to save the user in the database, we have to find a way to encrypt the password. For that, we are going to be using a package called bcrypt so go ahead and install it using this command:

npm i bcryptjs
npm i --save-dev @types/bcryptjs

Next, open the actions/register.ts file and add the following imports:

"use server";
import prisma from "@/lib/db";
import * as z from "zod";
import { RegisterSchema } from "@/schemas";
import bcrypt from "bcryptjs";

Next, update the register function as follows:

export const register = async (values: any) => {
  const validatedFields = RegisterSchema.safeParse(values);
  if (!validatedFields.success) {
    return {
      error: "Invalid fields!",
    };
  }

  const { name, email, password } = validatedFields.data;
  const hashedPassword = await bcrypt.hash(password, 10);
  const existingUser = await prisma.user.findUnique({
    where: { email: email },
  });

  if (existingUser) {
    return {
      error: "Email already taken!",
    };
  }
  await prisma.user.create({
    data: {
      name,
      email,
      password: hashedPassword,
    },
  });
  return {
    success: "User successfully created!",
  };
};

This declares an asynchronous function named register that takes an object values as its parameter. The register function validates input fields, hashes the password, checks for an existing user with the same email address, creates a new user if the email is not already taken, and returns appropriate success or error messages.

The input fields are validated using RegisterSchema.safeParse(values). If the validation fails (!validatedFields.success), it means that the input fields are invalid, so an error object with the message "Invalid fields!" is returned.

If the input fields are valid, the function proceeds to extract the name, email, and password from the validated fields. The password is then hashed using bcrypt with a salt of 10 rounds.

Next, the function checks if there is an existing user with the same email address. If an existing user is found, it returns an error object with the message "Email already taken!".

If the email is not already taken, the function proceeds to create a new user using prisma.user.create() with the provided name, email, and hashed password.

If the user is successfully created, a success object with the message "User successfully created!" is returned.

Displaying server action errors in our forms

Now, we need a way to display server success and error messages in our form. Go back to the components/register-form.tsx file and start by adding the following import:

import { useState } from "react";

In the RegisterComponent, add:

const [error, setError] = useState("");
const [success, setSuccess] = useState("");

Next, add the following code in above the "Sign up" button:

{
  success && (
    <div className="bg-green-500 text-white px-4 py-2 rounded-md">
      {success}
    </div>
  );
}
{
  error && (
    <div className="bg-red-500  text-white px-4 py-2 rounded-md">{error}</div>
  );
}

Then update the onSubmit function:

const onSubmit = (values: z.infer<typeof RegisterSchema>) => {
  startTransition(async () => {
    const data = await register(values);
    if (data && data.success) setSuccess(data.success);
    if (data && data.error) setError(data.error);
  });
};

You need to do the same things in the login component but only with error message because on success we will be redirected to another page.

Logging in with Prisma and server action

Now, we need to add login with Prisma database in the login server action.

In order to enable login we have to install the following package:

npm install next-auth@beta

Next, run the following variable to create a secret:

npx auth secret

This will create an authentication secret:

AUTH_SECRET=ZE9nHm/WKFLqqZtbr1w+YPOFPGLH6OEs4YhF8p1Ndn4=

We need to copy it to our .env file.

Next, create a auth.config.ts in the root folder and add:

import type { NextAuthConfig } from "next-auth";
import credentials from "next-auth/providers/credentials";
import { LoginSchema } from "./schemas";
import prisma from "@/lib/db";
import bcrypt from "bcryptjs";

export default {
  providers: [
    credentials({
      async authorize(credentials) {
        const validatedFields = LoginSchema.safeParse(credentials);

        if (validatedFields.success) {
          const { email, password } = validatedFields.data;

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

          if (!user || !user.password) return null;

          const passwordsMatch = await bcrypt.compare(password, user.password);
          if (passwordsMatch) return user;
        }

        return null;
      },
    }),
  ],
} satisfies NextAuthConfig;

This code configures NextAuth with a custom credentials provider for authentication. Here's more details:

  1. Import Statements:

    • NextAuthConfig: This is a type provided by NextAuth for defining the configuration options.
    • credentials: This is a built-in provider in NextAuth used for authenticating with an email and password.
    • LoginSchema: This is our schema defined using Zod for validating login credentials.
    • prisma: This imports the Prisma client instance for database operations.
    • bcrypt: This imports the bcrypt library for hashing and comparing passwords.
  2. Configuration Object:

    • The default export is an object containing configuration options for NextAuth.
    • It contains a providers array, which specifies the authentication providers to use. In this case, it includes a custom credentials provider.
    • Inside the credentials provider, there's an authorize function. This function receives the submitted credentials, validates them against a schema (LoginSchema), and then attempts to authenticate the user.
    • If the credentials are valid (validatedFields.success), it retrieves the user from the database using Prisma based on the provided email.
    • If a user is found and the hashed password matches the one stored in the database, the user object is returned, indicating successful authentication.
    • If any validation fails or authentication fails, null is returned, indicating authentication failure.

Overall, this configuration sets up NextAuth to authenticate users using an email and password stored in a database, leveraging Prisma for database operations and bcrypt for password hashing and comparison.

Next, create an auth.ts file and add the the Prisma adapter and destructure authConfig:

import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";

import authConfig from "@/auth.config";
import prisma from "@/lib/db";

export const {
  handlers: { GET, POST },
  auth,
  signIn,
  signOut,
} = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "jwt" },
  ...authConfig,
});

We also set a session configuration with a JWT strategy.

Next, create a route handler inside the app/api/auth/[...nextauth]/route.ts and add the following code:

export { GET, POST } from "@/auth";

Adding a dashboard page with sign out

Create a app/dashboard/page.tsx file and add this code:

import { auth } from "@/auth";
import { signOut } from "@/auth";
export default async function Dashboard() {
  const session = await auth();
  return (
    <div>
      {JSON.stringify(session)}
      <form
        action={async () => {
          "use server";
          await signOut();
        }}
      >
        <button type="submit"> Sign out</button>
      </form>
    </div>
  );
}

We import auth and signOut from "@/auth".

We define a default asynchronous function Dashboard that returns JSX.

Inside the function, we're awaiting the auth function to get the current session data.

We render the session data as a JSON string inside a <div>.

We add a form with a submit button for signing out.

It's all good now, we can register and sign in our users.

Using middleware for redirection

Now, we need to automatically redirect users to login page if the user is logged in or redirect the users to the dashboard if they are already logged in. We can achieve this using a middleware. In the root of your project create a middleware.ts file and start by adding the following import:

import authConfig from "@/auth.config";
import NextAuth from "next-auth";

We import the authConfig object from @/auth.config and the NextAuth module from the "next-auth" package.

Next, add the following code to configure NextAuth:

const { auth } = NextAuth(authConfig);

The NextAuth function is invoked with the authConfig object as an argument to configure NextAuth. The resulting object contains various functions and properties, including auth, which is used to authenticate requests.

Next, define an array authRoutes containing the paths for login and registration pages:

const authRoutes = ["/auth/login", "/auth/register"];

Next, add the middleware function:

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthRoute = authRoutes.includes(req.nextUrl.pathname);
  const isApiAuthRouter = req.nextUrl.pathname.startsWith("/api/auth");

  if (isApiAuthRouter) {
    return;
  }

  if (isAuthRoute) {
    if (isLoggedIn) {
      return Response.redirect(new URL("/dashboard", req.nextUrl));
    }
    return;
  }
  if (!isLoggedIn && !isAuthRoute) {
    return Response.redirect(new URL("/auth/login", req.nextUrl));
  }

  return;
});

Inside the middleware function, the following logic is performed:

It first checks if the request is targeting an API authentication route (/api/auth). If so, it skips further processing.

It then checks if the requested path is one of the authentication routes (/auth/login or /auth/register). If it is and the user is already logged in (isLoggedIn), it redirects the user to the dashboard page (/dashboard).

If the requested path is not an authentication route and the user is not logged in, it redirects the user to the login page (/auth/login).

If none of the above conditions are met, the middleware function does not perform any redirection.

Finally, add the following code:

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

The config object is exported, specifying a matcher pattern to determine which routes should be intercepted by the middleware. In this case, it matches all routes except those starting with /api, _next/static, _next/image, or favicon.ico.

Conclusion

This guide outlines the process of setting up an authentication app using NextAuth v5, Prisma, Zod, and Shadcn with Next.js 14:

Prerequisites: - Ensure Node.js 18.17 or later is installed on your development machine.

Setting up Next.js 14 project: - Use npx create-next-app@latest command to create a Next.js 14 project. - Choose TypeScript, ESLint, Tailwind CSS, and App Router during project setup.

Installing Dependencies: - Install necessary dependencies such as react, react-dom, next, typescript, @types/node, @types/react, @types/react-dom, postcss, tailwindcss, and eslint.

Initializing Shadcn: - Use npx shadcn-ui@latest init to initialize Shadcn. - Choose style and color preferences during setup.

Running the Next.js App: - Run the app using npm run dev. - Access the app at http://localhost:3000.

Building the Home Page with a Login Button: - Clear the existing markup in app/page.tsx. - Add a login button component using Shadcn.

Creating the Login Page and Form: - Create a login button component. - Create a login form component with necessary input fields. - Use Shadcn components like Card and Input for the login form. - Validate form input using Zod schema.

Handling Form Submission: - Create a server action to handle form submission. - Use bcrypt to hash passwords before storing them in the database. - Implement asynchronous processing to handle form submission delays.

Integrating Prisma Database: - Install Prisma and configure the Prisma schema. - Create models for users and accounts. - Generate migrations and apply them to synchronize the database. - Create a singleton instance of the Prisma Client for database interactions. - Use Prisma Client methods to query and manipulate data in the database.

Implementing NextAuth Authentication: - Install NextAuth and configure authentication settings. - Create a credentials provider for authentication. - Implement authorization logic to validate user credentials against the database. - Use JWT session strategy for authentication.

Adding Middleware for Redirection: - Create middleware to handle redirection based on user authentication status. - Redirect users to the login page if they are not logged in and try to access protected routes. - Redirect logged-in users away from authentication routes to prevent access.

This comprehensive setup ensures a robust authentication system with user registration, login, form validation, and database integration. Additionally, the use of middleware enhances security by enforcing access control and redirecting users appropriately.


  • Date: