How to Implement Authentication in Next.js 14 With NextAuth.js, Shadcn/ui, react-hook-form, and Zod

Written by ljaviertovar | Published 2024/03/01
Tech Story Tags: nextjs | typescript | implementing-authentication | shadcn-guide | how-to-implement-credentials | what-is-nextauth.js | hackernoon-top-story | what-is-shadcn

TLDRWithin the JavaScript ecosystem and, more specifically, in applications developed with Next.js, one of the most prominent libraries for handling authentication is NextAuth.js. This tool offers a simple and easy-to-implement solution for adding authentication to our applications. The best thing is its flexibility; it allows the integration of different authentication providers such as Google, Facebook, and Twitter, in addition to credential-based authentication, such as the classic email and password.via the TL;DR App

A Comprehensive Guide: Credentials (Email and Password)

Ensuring the security and privacy of users is more important than ever. Web authentication plays a crucial role in this aspect, serving as the first line of defense to protect users’ information and data.

Today, we have tools like NextAuth.js that make our job much easier, allowing us to implement different types of authentication easily in our Next.js applications.

In this series of tutorials, we are going to build a complete authentication system in Next.js 14, starting with the basics: authentication with email and password.

What Is NextAuth.js (Auth.js)?

Within the JavaScript ecosystem and, more specifically, in applications developed with Next.js, one of the most prominent libraries for handling authentication is NextAuth.js.

This tool offers a simple and easy-to-implement solution for adding authentication to our applications. The best thing is its flexibility; it allows the integration of different authentication providers such as Google, Facebook, and Twitter, in addition to credential-based authentication, such as the classic email and password.

Implementing Credentials Authentication

Credential authentication is especially useful in applications where you need full control over the authentication process and storage of user credentials, or when you do not want to rely on external authentication providers.

Setting Up

  1. Create a new Next.js project with the following command, and follow the steps indicated. We will use TypeScript and the src/ folder.

npx create-next-app@latest

2. Install the dependencies needed in the project. This time, we’ll use pnpm; you can use the package manager of your choice.

pnpm install next-auth prisma react-hook-form zod, bcrypt

For the UI, we’ll use Shadcn/ui.

pnpm dlx shadcn-ui@latest init

  • prisma: is an open-source database toolkit. We will use it to store user credentials.

  • next-auth: Authentication for Next.js.

  • react-hook-form: a library that helps you validate forms in React.

  • zod: a data validator.

  • bcrypt: to hash passwords.

  • shadcn/ui: a collection of reusable components.

3. Create the following structure for the project:

...
├── prisma/
...
├── src/
│   ├── actions/
│   │   └── auth-actions.tsx
│   ├── app/
│   │   ├── api/auth/[...nextauth]
│   │   │   └── route.ts
│   │   ├── auth/
│   │   │   ├── signin
│   │   │   │   └── page.tsx
│   │   │   └── signup
│   │   │       └── page.tsx
│   │   │   ...   
│   ├── components/
│   │   ├── auth/
│   │   │   ├── auth-buttons.tsx
│   │   │   ├── signin-form.tsx
│   │   │   ├── signup-form.tsx
│   │   │   └── user-nav.ts
│   │   ├── ui/
│   │   │   ...
│   │   ├── auth-provider.tsx
│   │   ├── icons.tsx
│   │   └── theme-provider.tsx
│   ├── lib/
│   │   ├── prisma.ts
│   │   ├── types.d.ts
│   │   └── utils.ts
│   ...
...

Setting Up Prisma

We’ll use Prisma to store and retrieve users in the database. Prisma allows the integration of different database types so you can use any database you need, we’ll use SQLite.

  1. Initializing Prisma.

npx prisma init --datasource-provider sqlite 

This creates the prism folder with its schemas.

  1. Creating the models.

To create the models, we’ll use the ones provided by @auth/prisma-adapter and customize them a little bit as follows:

prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
  output = "../../node_modules/.prisma/client"
}

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

...
model User {
  id            String    @id @default(cuid())
  username      String
  password      String
  email         String    @unique
  emailVerified DateTime?
  phone         String?
  image         String?
}
...

3. Creating the first migration.

npx prisma migrate dev --name first-migration

With this command, more files have been created in the Prisma folder, and the database has been synchronized with the models.

4. Prisma client

Finally, we create a Prisma Client.

lib/prisma.ts:

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

const globalForPrisma = global as unknown as {
  prisma: PrismaClient;
};

export const prisma = globalForPrisma.prisma || new PrismaClient();

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

export default prisma;

Setting Up NextAuth.js

  1. Creating env variables.

# Secret key for NextAuth.js, used for encryption and session security.  It should be a long,
# random string unique to your application.
NEXTAUTH_SECRET=XXX3B2CC28F123456C6934531CXXXXX

# Base URL for your Next.js app, used by NextAuth.js for redirects and callbacks.
NEXTAUTH_URL=http://localhost:3000/

  1. Creating the auth route.

This path allows handling all authentication requests (such as login, logout, and vendor callbacks) on a single endpoint.

src/app/api/auth/[...nextauth]

  1. Creating the providers.

...
// Imports the Prisma User type for typing.
import { User } from '@prisma/client'

// Configuration of authentication options for NextAuth.
export const authOptions: AuthOptions = {
  ...
 // Defines authentication providers, in this case, only credentials.
 providers: [
  CredentialsProvider({
   name: 'Credentials',
   // Defines the required fields for authentication.
   credentials: {
    email: { label: 'Email', type: 'text' },
    password: { label: 'Password', type: 'password' },
   },
   // Function to authenticate the user with the provided credentials.
   async authorize(credentials) {
    // Searches for the user in the database by email.
    const user = await prisma.user.findUnique({
     where: {
      email: credentials?.email,
     },
    })

    // Checks if the user exists and if the password is correct.
    if (!user) throw new Error('User name or password is not correct')

    if (!credentials?.password) throw new Error('Please Provide Your Password')
    const isPasswordCorrect = await bcrypt.compare(credentials.password, user.password)

    if (!isPasswordCorrect) throw new Error('User name or password is not correct')

    // Returns the user without including the password.
    const { password, ...userWithoutPass } = user
    return userWithoutPass
   },
  }),
 ],
}

// Exports the configured NextAuth handler to handle GET and POST requests.
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

4. Creating the Auth Provider.

src/components/auth-provider.tsx:

'use client'

import { SessionProvider } from 'next-auth/react'

export default function AuthProvider({ children }: { children: React.ReactNode }) {
 return <SessionProvider>{children}</SessionProvider>
}

This component acts as a session provider for Next.js applications that use NextAuth for authentication.

Wrapping components or pages in this provider grants them access to the session context, allowing child components to use NextAuth hooks and functionality, such as useSession, to access or modify the state of the user’s current session.

src/app/layout.tsx:

/* 
  All imports
*/
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
 return (
  <html
   lang='en'
   suppressHydrationWarning
  >
   <body className={`${inter.className} relative`}>
    <AuthProvider>
      <main>{children}</main>
    </AuthProvider>
   </body>
  </html>
 )
}

Setting Up the UI With Shadcn/ui

  1. Installing shadcn/ui following the documentation (step 2).

Would you like to use TypeScript (recommended)? yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › src/app/globals.css
Do you want to use CSS variables for colors? › yes
Are you using a custom tailwind prefix eg. tw-? Leave blank
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › yes

You can use the theme of your choice.

2. Implementing Dark mode

src/app/layout.tsx:

/* 
  All imports
*/
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
 return (
  <html
   lang='en'
   suppressHydrationWarning
  >
   <body className={`${inter.className} relative`}>
    <AuthProvider>
     <ThemeProvider
      attribute='class'
      defaultTheme='dark'
      enableSystem
      disableTransitionOnChange
     >
      <main>{children}</main>

      <Toaster />
     </ThemeProvider>
    </AuthProvider>
   </body>
  </html>
 )
}

2. Installing the following shadcn/ui components:

pnpm dlx shadcn-ui@latest add avatar button dropdown-menu form input label tabs toast

Creating Authentication Components

src/components/auth-buttons.tsx:

'use client'

import Link from 'next/link'
import { signIn, useSession } from 'next-auth/react'

import { Button } from '../ui/button'
import { UserNav } from './user-nav'

export default function AuthButtons() {
 // Use the useSession hook to access session data
 const { data: session } = useSession()

 return (
  <div className='flex justify-end gap-4'>
   {session && session.user ? (
    <UserNav user={session.user} />
   ) : (
    <>
     <Button
      size={'sm'}
      variant={'secondary'}
      onClick={() => signIn()}
     >
      Sign In
     </Button>
     <Button
      size={'sm'}
      asChild
      className='text-foreground'
     >
      <Link href='/auth/signup'>Sign Up</Link>
     </Button>
    </>
   )}
  </div>
 )
}

This component dynamically displays authentication options based on the user's session status. If the user is logged in, it shows user-specific navigation. Otherwise, it offers buttons to sign in or sign up, making use of Next.js's routing and NextAuth's authentication capabilities for a smooth user experience.

Modify the home page, and add the auth buttons. This is how it looks:

src/components/auth/signup-form.tsx:

'use client'

/*
  all imports
*/

// Function to register a new user
import { registerUser } from '@/actions/auth-actions'

// Define the validation schema for the signup form using Zod
const formSchema = z
 .object({
  username: z
   .string({
    required_error: 'Username is required',
   })
   .min(2, 'User name must have at least 2 characters')
   .max(12, 'Username must be up to 12 characters')
   .regex(new RegExp('^[a-zA-Z0-9]+$'), 'No special characters allowed!'),
  email: z.string({ required_error: 'Email is required' }).email('Please enter a valid email address'),
  password: z
   .string({ required_error: 'Password is required' })
   .min(6, 'Password must have at least 6 characters')
   .max(20, 'Password must be up to 20 characters'),
  confirmPassword: z
   .string({ required_error: 'Confirm your password is required' })
   .min(6, 'Password must have at least 6 characters')
   .max(20, 'Password must be up to 20 characters'),
 })
 .refine(values => values.password === values.confirmPassword, {
  message: "Password and Confirm Password doesn't match!",
  path: ['confirmPassword'],
 })

// Type inference for form inputs based on the Zod schema
type InputType = z.infer<typeof formSchema>

export function SignUpForm() {
 const [isLoading, setIsLoading] = useState(false)
 const { toast } = useToast() // Hook to show toast notifications

 // Initialize form handling with React Hook Form and Zod for validation
 const form = useForm<InputType>({
  resolver: zodResolver(formSchema),
 })

 // Handles form submission
 async function onSubmit(values: InputType) {
  try {
   setIsLoading(true)
   const { confirmPassword, ...user } = values // Exclude confirmPassword from data to be sent

   const response = await registerUser(user) // Register the user
   if ('error' in response) {
    toast({
     title: 'Something went wrong!',
     description: response.error,
     variant: 'success',
    })
   } else {
    toast({
     title: 'Account Created!',
     description: 'Your account has been created successfully! You can now login.',
    })
   }
  } catch (error) {
   console.error(error)
   toast({
    title: 'Something went wrong!',
    description: "We couldn't create your account. Please try again later!",
    variant: 'destructive',
   })
  } finally {
   setIsLoading(false)
  }
 }

 return (
  <Form {...form}>
   <form onSubmit={form.handleSubmit(onSubmit)}>
    <div className='grid gap-2'>
     // Each FormField validates and displays an input
     <FormField
      control={form.control}
      name='username'
      render={({ field }) => (
       <FormItem>
        <FormControl>
         <div className='flex items-center gap-2'>
          <Icons.user
           className={`${form.formState.errors.username ? 'text-destructive' : 'text-muted-foreground'} `}
          />
          <Input
           placeholder='Your Username'
           className={`${form.formState.errors.username && 'border-destructive bg-destructive/30'}`}
           {...field}
          />
         </div>
        </FormControl>
        <FormMessage />
       </FormItem>
      )}
     />

     // Repeated structure for email, password, and confirmPassword with respective validations and icons
     
     <Button
      className='text-foreground mt-4'
      disabled={isLoading} // Disable button during form submission
     >
      {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading icon if isLoading is true
      Sign Up
     </Button>
    </div>
   </form>
  </Form>
 )
}

This component encapsulates a user registration form, utilizing react-hook-form for form state management and Zod for schema validation.

While this is not a tutorial on these technologies, we are only using the basics of them, if you have more questions, you can go to their documentation.

I have added some more styles to the page, and it looks like this:

src/actions/auth-action/ts:

'use server'

/*
  all imports
*/

export async function registerUser(user: Omit<User, 'id' | 'phone' | 'emailVerified' | 'image'>) {
 try {
  // Attempt to create a new user record in the database
  const result = await prisma.user.create({
   data: {
    ...user,
    // Hash the password before storing it
    password: await bcrypt.hash(user.password, 10),
   },
  })

  return result
 } catch (error) {
  console.log(error)
  // Handle known request errors from Prisma
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
   // Check for unique constraint failure (e.g., email already exists)
   if (error.code === 'P2002') {
    return { error: 'Email already exists.' }
   }
  }

  // Return a generic error message for any other errors
  return { error: 'An unexpected error occurred.' }
 }
}

registerUser function is designed to securely register a new user by creating a record in the database with the provided user information, excluding fields like idphoneemailVerified, and image.

It uses bcrypt to hash the user's password for secure storage.

To test our sign-up and validate that the user is registering correctly, we need to add some callbacks; these are functions that allow you to customize the behavior of authentication and session management.

src/app/api/auth/[...nextauth]:

...

export const authOptions: AuthOptions = {
 // Define custom pages for authentication flow
 pages: {
  signIn: '/auth/signin', // Custom sign-in page
 },
 // Configure session management to use JSON Web Tokens (JWT)
 session: {
  strategy: 'jwt',
 },
 // JWT configuration, including secret for token signing
 jwt: {
  secret: process.env.NEXTAUTH_SECRET, // Secret used to sign the JWT, stored in environment variables
 },

...

// Callbacks for customizing JWT and session behaviors
 callbacks: {
  // Callback to modify the JWT content. Adds user information if available.
  async jwt({ token, user }) {
   if (user) token.user = user as User // Cast user object to User type and assign to token
   return token
  },

  // Callback to modify session content. Adds user information to the session.
  async session({ token, session }) {
   session.user = token.user // Assign user information from token to session
   return session
  },
 },
}
...

Callback jwt : this callback is executed whenever a JSON Web Token (JWT) is created or updated during the authentication lifecycle. It allows you to modify the contents of the token before it is signed and sent to the client or stored on the server.

This is useful for adding additional information to the token, which may be relevant to your application logic.

Callback session : this callback is called each time the session data is read, such as during server-side rendering or in protected API requests. It allows session data to be modified before it is sent to the client.

This is especially useful for adding or modifying session data based on information stored in the JWT or other criteria.

Finally, we need to extend the NextAuth Session and JWT type definitions to include additional user information.

src/lib/types.d.ts:

import { User } from '@prisma/client'

declare module 'next-auth' {
 interface Session {
  user: User
 }
}

declare module 'next-auth/jwt' {
 interface JWT {
  user: User
 }
}

Now, if we fill out the form and submit it, we will be able to see the success toast. To verify that the user was saved in the database, we can see graphically the tables created by Prisma with the following command:

nxp prisma studio

We will have available the following route http://localhost:5555


src/components/auth/user-nav.tsx:

/*
   all imports
*/

interface Props {
 user: User // Expect a user object of type User from Prisma client
}

export function UserNav({ user }: Props) {
 return (
  <DropdownMenu>
   <DropdownMenuTrigger asChild>
    <Button
     variant='ghost' 
     className='relative h-8 w-8 rounded-full'
    >
     <Avatar className='h-9 w-9'>
      <AvatarImage
       src='/img/avatars/01.png'
       alt='' 
      />
      <AvatarFallback>UU</AvatarFallback>
     </Avatar>
    </Button>
   </DropdownMenuTrigger>
   <DropdownMenuContent
    className='w-56'
    align='end'
    forceMount
   >
    <DropdownMenuLabel className='font-normal'>
     <div className='flex flex-col space-y-1'>
      <p className='text-sm font-medium leading-none'>{user.username}</p>
      <p className='text-xs leading-none text-muted-foreground'>{user.email}</p>
     </div>
    </DropdownMenuLabel>
    <DropdownMenuSeparator />
    <DropdownMenuItem>
     <Link
      href={'/api/auth/signout'} // Link to the signout API route
      className='w-full'
     >
      Sign Out
     </Link>
    </DropdownMenuItem>
   </DropdownMenuContent>
  </DropdownMenu>
 )
}

src/components/auth/signin-form.tsx:

/* 
  all imports 
*/

// Schema definition for form validation using Zod
const formSchema = z.object({
 email: z.string({ required_error: 'Please enter your email' }).email('Please enter a valid email address'),
 password: z.string({
  required_error: 'Please enter your password',
 }),
})

// Type inference for form inputs based on the Zod schema
type InputType = z.infer<typeof formSchema>

// Props definition, optionally including a callback URL
interface Props {
 callbackUrl?: string
}

export function SignInForm({ callbackUrl }: Props) {
 const [isLoading, setIsLoading] = useState(false)
 const { toast } = useToast()

 const router = useRouter() // Hook to control routing

 const form = useForm<InputType>({
  resolver: zodResolver(formSchema), // Set up Zod as the form validation resolver
 })

 // Function to handle form submission
 async function onSubmit(values: InputType) {
  try {
   setIsLoading(true)

   // Attempt to sign in using the 'credentials' provider
   const response = await signIn('credentials', {
    redirect: false, // Prevent automatic redirection
    email: values.email,
    password: values.password,
   })

   // Handle unsuccessful sign in attempts
   if (!response?.ok) {
    toast({
     title: 'Something went wrong!',
     description: response?.error,
     variant: 'destructive',
    })
    return
   }

   toast({
    title: 'Welcome back! ',
    description: 'Redirecting you to your dashboard!',
   })
   router.push(callbackUrl ? callbackUrl : '/') // Redirect to the callback URL or home page
  } catch (error) {
   toast({
    title: 'Something went wrong!',
    description: "We couldn't create your account. Please try again later!",
    variant: 'destructive',
   })
  } finally {
   setIsLoading(false)
  }
 }

 return (
  <Form {...form}>
   <form onSubmit={form.handleSubmit(onSubmit)}>
    <div className='grid gap-2'>
     <div className='grid gap-1'>
      <FormField
       control={form.control}
       name='email'
       render={({ field }) => (
        <FormItem>
         <FormControl>
          <div className='flex items-center gap-2'>
           <Icons.email className={`${form.formState.errors.email ? 'text-destructive' : 'text-muted-foreground'} `}/>
           <Input
            type='email'
            placeholder='Your Email'
            className={`${form.formState.errors.email && 'border-destructive bg-destructive/30'}`}
            {...field}
           />
          </div>
         </FormControl>
         <FormMessage />
        </FormItem>
       )}
      />
      {/* Password field */}
      {/* Similar structure to email field, customized for password input */}
     </div>
     <Button
      className='text-foreground mt-4'
      disabled={isLoading} // Disable button while loading
     >
      {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />} // Show loading spinner when processing
      Sign In
     </Button>
    </div>
   </form>
  </Form>
 )
}

🎉 It's done!

We’ve finished implementing a basic authentication with NextAuth.js. There are still many things to do to have a complete authentication system, and we will cover them in the next tutorials.

🔗 Repo here

Conclusion

In summary, we have explored how to implement and customize an authentication system in Next.js using NextAuth, how to extend sessions and JWTs to enrich user management, and how to handle forms with effective validation using react-hook-form and Zod.


Want to connect with the Author?

Love connecting with friends all around the world on 𝕏.


Also published here


Written by ljaviertovar | ☕ FrontEnd engineer 👨‍💻 Indie maker ✍️ Tech writer
Published by HackerNoon on 2024/03/01