User Authentication with Auth.js in Next.js App Router
Yu Hamada
Posted on November 10, 2024
Table of Contents
Initial Setup
Implementing Authentication: Credentials and Google OAuth
- Setting up prisma
- Credentials
- Add Google OAuth Provider
- Creating Login and Signup page
- Folder Structure
Initial Setup
Install
npm install next-auth@beta
// env.local
AUTH_SECRET=GENERATETD_RANDOM_VALUE
Configure
NextAuthConfig settings
// src/auth.ts
import NextAuth from "next-auth"
export const config = {
providers: [],
}
export const { handlers, signIn, signOut, auth } = NextAuth(config)
It should be put inside of src folder
Providers means in Auth.js are services that can be used to sign in a user. There are four ways a user can be signed in.
- Using a built-in OAuth Provider(e.g Github, Google, etc...)
- Using a custom OAuth Provider
- Using Email
- Using Credentials
https://authjs.dev/reference/nextjs#providers
Route Handler Setup
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
This file is used for setting route handler with Next.js App Router.
Middleware
// src/middleware.ts
import { auth } from "@/auth"
export default auth((req) => {
// Add your logic here
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], // It's default setting
}
Write inside the src folder.
If written outside the src folder, middleware will not work.
Middleware is a function that allows you to run code before a request is completed. It is particularly useful for protecting routes and handling authentication across your application.
Matcher is a configuration option for specifying which routes middleware should apply to. It helps optimize performance by running middleware only on necessary routes.
Example matcher: ['/dashboard/:path*']
applies middleware only to dashboard routes.
https://authjs.dev/getting-started/session-management/protecting?framework=express#nextjs-middleware
Get Session in Server Component
// src/app/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
export default async function page() {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<div>
<h1>Hello World!</h1>
<img src={session.user.image} alt="User Avatar" />
</div>
)
}
Get Session in Client Component
// src/app/page.tsx
"use client"
import { useSession } from "next-auth/react"
import { useRouter } from "next/navigation"
export default async function page() {
const { data: session } = useSession()
const router = useRouter()
if (!session.user) {
router.push('/login')
}
return (
<div>
<h1>Hello World!</h1>
<img src={session.user.image} alt="User Avatar" />
</div>
)
}
// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { SessionProvider } from "next-auth/react"
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<SessionProvider>
{children}
</SessionProvider>
</body>
</html>
);
}
Folder structure
/src
/app
/api
/auth
[...nextauth]
/route.ts // Route Handler
layout.tsx
page.tsx
auth.ts // Provider, Callback, Logic etc
middleware.ts // A function before request
Implementing Authentication: Credentials and Google OAuth
Setting up prisma
// prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
accounts Account[]
sessions Session[]
}
model Account {
// ... (standard Auth.js Account model)
}
model Session {
// ... (standard Auth.js Session model)
}
// ... (other necessary models)
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
Credentials
Credentials, in the context of authentication, refer to a method of verifying a user's identity using information that the user provides, typically a username (or email) and password.
We can add credentials in src/auth.ts
.
// src/auth.ts
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import bcrypt from 'bcryptjs';
export const config = {
adapter: PrismaAdapter(prisma),
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" }
},
authorize: async (credentials): Promise<any> => {
if (!credentials?.email || !credentials?.password) {
return null;
}
try {
const user = await prisma.user.findUnique({
where: {
email: credentials.email as string
}
})
if (!user || !user.hashedPassword) {
return null
}
const isPasswordValid = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
)
if (!isPasswordValid) {
return null
}
return {
id: user.id as string,
email: user.email as string,
name: user.name as string,
}
} catch (error) {
console.error('Error during authentication:', error)
return null
}
}
})
],
secret: process.env.AUTH_SECRET,
pages: {
signIn: '/login',
},
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.email = user.email
token.name = user.name
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.email = token.email as string
session.user.name = token.name as string
}
return session
},
},
} satisfies NextAuthConfig;
export const { handlers, auth, signIn, signOut } = NextAuth(config);
adapters
:
- modules that connect your authentication system to your database or data storage solution.
secret
:
- This is a random string used to hash tokens, sign/encrypt cookies, and generate cryptographic keys.
- It's crucial for security and should be kept secret.
- In this case, it's set using an environment variable
AUTH_SECRET
.
pages
:
- This object allows you to customize the URLs for authentication pages.
- In your example,
signIn: '/login'
means the sign-in page will be at the '/login' route instead of the default '/api/auth/signin'.
session
:
- This configures how sessions are handled.
-
strategy: "jwt"
means JSON Web Tokens will be used for session management instead of database sessions.
callbacks
:
- These are functions that are called at various points in the authentication flow, allowing you to customize the process.
jwt
callback:
- This runs when a JWT is created or updated.
- In your code, it's adding user information (id, email, name) to the token.
session
callback:
- This runs whenever a session is checked.
- Your code is adding the user information from the token to the session object.
Add Google OAuth Provider
Setting Google OAuth application
Create new OAuth Client ID from GCP Console > APIs & Services > Credentials
Once created, save your Client ID and Client Secret for later use.
Setting Redirect URI
When we work in local, set http://localhost:3000/api/auth/callback/google
In production environment, just replace http://localhost:3000
with https://------
.
Setup Environment Variables
// .env.local
GOOGLE_CLIENT_ID={CLIENT_ID}
GOOGLE_CLIENT_SECRET={CLIENT_SECRET}
Setup Provider
// src/auth.ts
import GoogleProvider from "next-auth/providers/google" // add this import.
export const { handlers, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
// ... (previous Credentials configuration)
}),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
// ... other configurations
})
https://authjs.dev/getting-started/authentication/oauth
Creating Login and Signup page
//// UI pages
// src/app/login/LoginPage.tsx
import Link from 'next/link'
import { LoginForm } from '@/components/auth/LoginForm'
import { Separator } from '@/components/auth/Separator'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
export default function LoginPage() {
return (
<AuthLayout title="Welcome Back!">
<LoginForm />
<Separator />
<GoogleAuthButton text="Sign In with Google" />
<div className="mt-6 text-center">
<p className="text-sm text-gray-400">
Do not have an account?{' '}
<Link href="/signup" className="pl-1.5 font-medium text-[#3ba55c] hover:text-[#2d7d46]">
Sign up
</Link>
</p>
</div>
</AuthLayout>
)
}
// src/app/signup/SignupPage.tsx
import Link from 'next/link'
import { SignUpForm } from '@/components/auth/SignUpForm'
import { Separator } from '@/components/auth/Separator'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
export default function SignUpPage() {
return (
<AuthLayout title="Welcome!">
<SignUpForm />
<Separator />
<GoogleAuthButton text="Sign Up with Google" />
<div className="mt-6 text-center">
<p className="text-sm text-gray-400">
Already have an account?{' '}
<Link href="/login" className="pl-1.5 font-medium text-[#3ba55c] hover:text-[#2d7d46]">
Sign in
</Link>
</p>
</div>
</AuthLayout>
)
}
//// Components
// src/components/auth/AuthLayout.tsx
import React from 'react'
interface AuthLayoutProps {
children: React.ReactNode
title: string
}
export const AuthLayout: React.FC<AuthLayoutProps> = ({ children, title }) => {
return (
<div className="min-h-screen bg-[#36393f] flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
{title}
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-[#2f3136] py-8 px-4 shadow sm:rounded-lg sm:px-10">
{children}
</div>
</div>
</div>
)
}
// src/components/auth/GoogleAuthButton.tsx
import { signIn } from "@/auth"
import { Button } from "@/components/ui/button"
interface GoogleAuthButtonProps {
text: string
}
export const GoogleAuthButton: React.FC<GoogleAuthButtonProps> = ({ text }) => {
return (
<form
action={async () => {
"use server"
await signIn("google", { redirectTo: '/' })
}}
>
<Button
className="my-1 w-full bg-white text-gray-700 hover:bg-slate-100"
>
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
<path d="M1 1h22v22H1z" fill="none"/>
</svg>
{text}
</Button>
</form>
)
}
// src/components/auth/LoginForm.tsx
'use client'
import { useTransition } from "react"
import { useForm } from "react-hook-form"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { LoginResolver, LoginSchema } from "@/schema/login"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { FormError } from "@/components/auth/FormError"
import { FormSuccess } from "@/components/auth/FormSuccess"
import { login } from "@/app/actions/auth/login"
import { Loader2 } from "lucide-react"
export const LoginForm = () => {
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const [isPending, startTransition] = useTransition()
const router = useRouter();
const form = useForm<LoginSchema>({
defaultValues: { email: '', password: ''},
resolver: LoginResolver,
})
const onSubmit = (formData: LoginSchema) => {
startTransition(() => {
setError('')
setSuccess('')
login(formData)
.then((data) => {
if (data.success) {
setSuccess(data.success)
router.push('/setup')
} else if (data.error) {
setError(data.error)
}
})
.catch((data) => {
setError(data.error)
})
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-3">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Email address</FormLabel>
<FormControl>
<Input
placeholder="Enter your email address"
{...field}
disabled={isPending}
className="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
disabled={isPending}
className="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormError message={error} />
<FormSuccess message={success} />
</div>
<Button
type="submit"
disabled={isPending}
className="mt-8 w-full bg-[#3ba55c] hover:bg-[#2d7d46] text-white"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Login'
)}
</Button>
</form>
</Form>
)
}
// src/components/auth/SignUpForm.tsx
'use client'
import { useTransition } from "react"
import { useForm } from "react-hook-form"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { SignUpResolver, SignUpSchema } from "@/schema/signup"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { FormError } from "@/components/auth/FormError"
import { FormSuccess } from "@/components/auth/FormSuccess"
import { signUp } from "@/app/actions/auth/signup"
import { Loader2 } from "lucide-react"
export const SignUpForm = () => {
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const [isPending, startTransition] = useTransition()
const router = useRouter();
const form = useForm<SignUpSchema>({
defaultValues: { name: '', email: '', password: ''},
resolver: SignUpResolver,
})
const onSubmit = async (formData: SignUpSchema) => {
startTransition(() => {
setError('')
setSuccess('')
signUp(formData)
.then((data) => {
if (data.success) {
setSuccess(data.success)
router.push('/login')
} else if (data.error) {
setError(data.error)
}
})
.catch((data) => {
setError(data.error)
})
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Username</FormLabel>
<FormControl>
<Input
placeholder="Enter your name"
{...field}
disabled={isPending}
className="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Email address</FormLabel>
<FormControl>
<Input
placeholder="Enter your email address"
{...field}
disabled={isPending}
className="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
disabled={isPending}
className="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormError message={error} />
<FormSuccess message={success} />
</div>
<Button
type="submit"
disabled={isPending}
className="mt-8 w-full bg-[#3ba55c] hover:bg-[#2d7d46] text-white"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
'Sign Up'
)}
</Button>
</form>
</Form>
)
}
// src/components/auth/FormSuccess.tsx
import { CheckCircledIcon } from "@radix-ui/react-icons";
interface FormSuccessProps {
message?: string;
}
export const FormSuccess = ({ message }: FormSuccessProps) => {
if (!message) return null;
return (
<div className="bg-emerald-500/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-emerald-500">
<CheckCircledIcon className="h-4 w-4" />
<p>{message}</p>
</div>
);
};
// src/components/auth/FormError.tsx
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
interface FormErrorProps {
message?: string;
}
export const FormError = ({ message }: FormErrorProps) => {
if (!message) return null;
return (
<div className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<p>{message}</p>
</div>
);
};
// src/components/auth/Separator.tsx
export const Separator = () => {
return (
<div className="my-4 relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-600" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-[#2f3136] text-gray-400">Or continue with</span>
</div>
</div>
)
}
//// Actions
// src/app/actions/auth/login.ts
'use server'
import { LoginSchema, loginSchema } from '@/schema/login'
import { signIn } from '@/auth'
export const login = async (formData: LoginSchema) => {
const email = formData['email'] as string
const password = formData['password'] as string
const validatedFields = loginSchema.safeParse({
email: formData.email as string,
password: formData.password as string,
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Login failed. Please check your input.'
}
}
try {
const result = await signIn('credentials', {
redirect: false,
callbackUrl: '/setup',
email,
password
})
if (result?.error) {
return { error : 'Invalid email or password'}
} else {
return { success : 'Login successfully'}
}
} catch {
return { error : 'Login failed'}
}
}
// src/app/actions/auth/signup.ts
'use server'
import bcrypt from 'bcryptjs'
import { SignUpSchema, signUpSchema } from "@/schema/signup"
import { prisma } from '@/lib/prisma';
export const signUp = async (formData: SignUpSchema) => {
const validatedFields = signUpSchema.safeParse({
name: formData.name as string,
email: formData.email as string,
password: formData.password as string,
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Sign up failed. Please check your input.'
}
}
try {
const hashedPassword = await bcrypt.hash(validatedFields.data.password, 10);
const existingUser = await prisma.user.findUnique({
where: { email: validatedFields.data.email }
})
if (existingUser) {
return { error: 'User already exists!' }
}
await prisma.user.create({
data: {
name: validatedFields.data.name,
email: validatedFields.data.email,
hashedPassword: hashedPassword,
},
});
return { success: 'User created successfully!' }
} catch (error) {
return { error : `Sign up failed`}
}
}
//// Validations
// src/schema/login.ts
import * as z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
export const loginSchema = z.object({
email: z.string().email('This is not valid email address'),
password: z
.string()
.min(8, { message: 'Password must contain at least 8 characters' }),
});
export type LoginSchema = z.infer<typeof loginSchema>;
export const LoginResolver = zodResolver(loginSchema);
// src/schema/signup.ts
import * as z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
export const signUpSchema = z.object({
name: z.string().min(1, {
message: 'Name is required'
}),
email: z.string().email('This is not valid email address'),
password: z
.string()
.min(8, { message: 'Password must contain at least 8 characters' }),
});
export type SignUpSchema = z.infer<typeof signUpSchema>;
export const SignUpResolver = zodResolver(signUpSchema);
// src/middleware.ts
import { NextResponse } from 'next/server'
import { auth } from "@/auth"
export default auth((req) => {
const { nextUrl, auth: session } = req
const isLoggedIn = !!session
const isLoginPage = nextUrl.pathname === "/login"
const isSignUpPage = nextUrl.pathname === "/signup"
const isSetupPage = nextUrl.pathname === "/setup"
// If trying to access /setup while not logged in
if (!isLoggedIn && isSetupPage) {
const loginUrl = new URL("/login", nextUrl.origin)
return NextResponse.redirect(loginUrl)
}
// If trying to access /login or /signup while already logged in
if (isLoggedIn && (isLoginPage || isSignUpPage)) {
const dashboardUrl = new URL("/", nextUrl.origin)
return NextResponse.redirect(dashboardUrl)
}
// For all other cases, allow the request to pass through
return NextResponse.next()
})
export const config = {
matcher: ["/login","/signup", "/setup", "/"],
};
Folder Structure
/src
/app
/actions
/login.ts // Login Action
/signup.ts // Signup Action
/api
/auth
[...nextauth]
/route.ts
/login
page.tsx // Login Page
/signup
page.tsx // Sign Up Page
layout.tsx
page.tsx
/components
/auth
AuthLayout.tsx
GoogleAuthButton.tsx
LoginForm.tsx
SignupForm.tsx
FormSuccess.tsx
FormError.tsx
Separator.tsx
/schema
login.ts
signup.ts
auth.ts // in src folder
middleware.ts // in src folder
Posted on November 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.