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:
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.
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 anauthorize
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: