NextAuth.js v5 Tutorial: Complete Authentication for Next.js
ID | EN

NextAuth.js v5 Tutorial: Complete Authentication for Next.js

Rabu, 15 Jan 2025

If you’re building a Next.js application and need authentication, NextAuth.js (now known as Auth.js) is a solid choice. In version 5, there are many significant changes that make setup simpler and performance better. Let’s dive in from start to production-ready!

Why NextAuth.js?

Before getting into the tutorial, why should you use NextAuth.js?

  • Zero-config for OAuth - Setting up Google, GitHub, and other providers only takes a few lines
  • Built-in session management - No need to build from scratch
  • Database adapter - Seamless integration with Prisma, Drizzle, or other databases
  • TypeScript-first - Full type safety
  • Edge-compatible - Can run on Vercel Edge Functions

What’s New in NextAuth.js v5 (Auth.js)

Version 5 brings major changes:

// v4 (old)
import NextAuth from "next-auth"

// v5 (new)
import NextAuth from "next-auth"
// Or for universal support:
import { Auth } from "@auth/core"

Key changes:

  • Unified configuration - One auth.ts file for all config
  • Native App Router support - Route handlers instead of API routes
  • Edge Runtime ready - Deploy on edge without issues
  • Middleware authentication - Protect routes directly in middleware
  • Improved TypeScript - Better type inference

Setup and Installation

Let’s start from scratch. Make sure you already have a Next.js 14+ project with App Router.

# Install NextAuth.js v5
npm install next-auth@beta

# Or with pnpm
pnpm add next-auth@beta

Generate a secret for production:

npx auth secret

Add to .env.local:

AUTH_SECRET="your-generated-secret-here"

# OAuth Providers (optional)
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"

# Database (optional)
DATABASE_URL="postgresql://..."

auth.ts Configuration

Create an auth.ts file at the project root or in the src/ folder:

// auth.ts
import NextAuth from "next-auth"
import { NextAuthConfig } from "next-auth"

export const authConfig: NextAuthConfig = {
  providers: [],
  pages: {
    signIn: "/login",
    error: "/auth/error",
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard")
      
      if (isOnDashboard) {
        if (isLoggedIn) return true
        return false // Redirect to login
      }
      
      return true
    },
  },
}

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig)

Create a route handler at app/api/auth/[...nextauth]/route.ts:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"

export const { GET, POST } = handlers

Credentials Provider (Email/Password)

For traditional login with email and password:

// auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { getUserByEmail } from "@/lib/user"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }

        const user = await getUserByEmail(credentials.email as string)
        
        if (!user || !user.password) {
          return null
        }

        const passwordMatch = await bcrypt.compare(
          credentials.password as string,
          user.password
        )

        if (!passwordMatch) {
          return null
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        }
      },
    }),
  ],
  // ... rest of config
})

Create a login form component:

// components/login-form.tsx
"use client"

import { signIn } from "next-auth/react"
import { useState } from "react"
import { useRouter } from "next/navigation"

export function LoginForm() {
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
  const [error, setError] = useState("")
  const [loading, setLoading] = useState(false)
  const router = useRouter()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError("")

    try {
      const result = await signIn("credentials", {
        email,
        password,
        redirect: false,
      })

      if (result?.error) {
        setError("Invalid email or password")
        return
      }

      router.push("/dashboard")
      router.refresh()
    } catch {
      setError("An error occurred")
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      {error && (
        <div className="bg-red-100 text-red-600 p-3 rounded">
          {error}
        </div>
      )}
      
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          className="w-full p-2 border rounded"
        />
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          className="w-full p-2 border rounded"
        />
      </div>

      <button
        type="submit"
        disabled={loading}
        className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {loading ? "Loading..." : "Login"}
      </button>
    </form>
  )
}

OAuth Providers (Google, GitHub)

Add OAuth providers:

// auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
    }),
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
  ],
})

OAuth buttons component:

// components/oauth-buttons.tsx
"use client"

import { signIn } from "next-auth/react"

export function OAuthButtons() {
  return (
    <div className="space-y-2">
      <button
        onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
        className="w-full flex items-center justify-center gap-2 p-2 border rounded hover:bg-gray-50"
      >
        <GoogleIcon />
        Continue with Google
      </button>

      <button
        onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
        className="w-full flex items-center justify-center gap-2 p-2 border rounded hover:bg-gray-50"
      >
        <GitHubIcon />
        Continue with GitHub
      </button>
    </div>
  )
}

Database Adapter (Prisma)

To persist sessions and users to the database:

npm install @auth/prisma-adapter

Update Prisma schema:

// prisma/schema.prisma
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  password      String?
  role          String    @default("user")
  accounts      Account[]
  sessions      Session[]
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

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

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

Configure adapter:

// auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "jwt" },
  providers: [
    // ... providers
  ],
})

Middleware for Route Protection

Create middleware.ts at the project root:

// middleware.ts
import { auth } from "@/auth"

export default auth((req) => {
  const isLoggedIn = !!req.auth
  const { pathname } = req.nextUrl

  // Protected routes
  const protectedRoutes = ["/dashboard", "/settings", "/profile"]
  const isProtected = protectedRoutes.some((route) =>
    pathname.startsWith(route)
  )

  if (isProtected && !isLoggedIn) {
    const loginUrl = new URL("/login", req.url)
    loginUrl.searchParams.set("callbackUrl", pathname)
    return Response.redirect(loginUrl)
  }

  // Redirect logged in users away from auth pages
  const authRoutes = ["/login", "/register"]
  const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route))

  if (isAuthRoute && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.url))
  }
})

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

Session and User Data

Server Component

// app/dashboard/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"

export default async function DashboardPage() {
  const session = await auth()

  if (!session) {
    redirect("/login")
  }

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {session.user?.name}!</p>
      <p>Email: {session.user?.email}</p>
    </div>
  )
}

Client Component

// components/user-menu.tsx
"use client"

import { useSession, signOut } from "next-auth/react"

export function UserMenu() {
  const { data: session, status } = useSession()

  if (status === "loading") {
    return <div>Loading...</div>
  }

  if (!session) {
    return <a href="/login">Login</a>
  }

  return (
    <div className="flex items-center gap-4">
      <span>{session.user?.name}</span>
      <button
        onClick={() => signOut({ callbackUrl: "/" })}
        className="text-red-600 hover:underline"
      >
        Logout
      </button>
    </div>
  )
}

SessionProvider Setup

// app/providers.tsx
"use client"

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

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>
}
// app/layout.tsx
import { Providers } from "./providers"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Role-Based Access Control (RBAC)

Extend Session Type

// types/next-auth.d.ts
import { DefaultSession } from "next-auth"

declare module "next-auth" {
  interface Session {
    user: {
      id: string
      role: string
    } & DefaultSession["user"]
  }

  interface User {
    role: string
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    id: string
    role: string
  }
}

Configure Callbacks

// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
  // ... other config
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.role = user.role
      }
      return token
    },
    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id
        session.user.role = token.role
      }
      return session
    },
  },
})

RequireRole Component

// components/require-role.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"

interface RequireRoleProps {
  allowedRoles: string[]
  children: React.ReactNode
}

export async function RequireRole({
  allowedRoles,
  children,
}: RequireRoleProps) {
  const session = await auth()

  if (!session) {
    redirect("/login")
  }

  if (!allowedRoles.includes(session.user.role)) {
    redirect("/unauthorized")
  }

  return <>{children}</>
}

Usage:

// app/admin/page.tsx
import { RequireRole } from "@/components/require-role"

export default function AdminPage() {
  return (
    <RequireRole allowedRoles={["admin"]}>
      <div>
        <h1>Admin Dashboard</h1>
        {/* Admin content */}
      </div>
    </RequireRole>
  )
}

Custom Pages (Sign-in, Error)

Create a custom sign-in page:

// app/login/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
import { LoginForm } from "@/components/login-form"
import { OAuthButtons } from "@/components/oauth-buttons"

export default async function LoginPage() {
  const session = await auth()

  if (session) {
    redirect("/dashboard")
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow">
        <div className="text-center">
          <h2 className="text-3xl font-bold">Login</h2>
          <p className="mt-2 text-gray-600">
            Sign in to your account
          </p>
        </div>

        <OAuthButtons />

        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t border-gray-300" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="px-2 bg-white text-gray-500">
              Or with email
            </span>
          </div>
        </div>

        <LoginForm />

        <p className="text-center text-sm text-gray-600">
          Don't have an account?{" "}
          <a href="/register" className="text-blue-600 hover:underline">
            Register
          </a>
        </p>
      </div>
    </div>
  )
}

Custom error page:

// app/auth/error/page.tsx
"use client"

import { useSearchParams } from "next/navigation"
import Link from "next/link"

const errorMessages: Record<string, string> = {
  Configuration: "There's a problem with the server configuration.",
  AccessDenied: "You don't have access to this page.",
  Verification: "The verification link has expired or is invalid.",
  Default: "An error occurred during login.",
}

export default function AuthErrorPage() {
  const searchParams = useSearchParams()
  const error = searchParams.get("error")

  const errorMessage = error
    ? errorMessages[error] || errorMessages.Default
    : errorMessages.Default

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full text-center space-y-4 p-8 bg-white rounded-xl shadow">
        <div className="text-red-500 text-5xl">⚠️</div>
        <h1 className="text-2xl font-bold">Authentication Error</h1>
        <p className="text-gray-600">{errorMessage}</p>
        <Link
          href="/login"
          className="inline-block bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
        >
          Back to Login
        </Link>
      </div>
    </div>
  )
}

Security Best Practices

Some security tips you must follow:

1. Environment Variables

# DO NOT commit to repository!
AUTH_SECRET="use-a-long-random-string"
AUTH_TRUST_HOST=true # Only for production

2. CSRF Protection

NextAuth.js v5 already includes CSRF protection by default. Make sure you don’t disable it:

// ❌ Don't do this
export const { handlers } = NextAuth({
  trustHost: true,
  // ...
})

// ✅ Leave default or set explicitly
export const { handlers } = NextAuth({
  // trustHost will auto-detect
})

3. Rate Limiting

Add rate limiting for login:

// lib/rate-limit.ts
import { LRUCache } from "lru-cache"

type RateLimitOptions = {
  interval: number
  uniqueTokenPerInterval: number
}

export function rateLimit(options: RateLimitOptions) {
  const tokenCache = new LRUCache({
    max: options.uniqueTokenPerInterval,
    ttl: options.interval,
  })

  return {
    check: (token: string, limit: number) =>
      new Promise<void>((resolve, reject) => {
        const tokenCount = (tokenCache.get(token) as number[]) || [0]
        if (tokenCount[0] === 0) {
          tokenCache.set(token, [1])
        }
        tokenCount[0] += 1

        const currentUsage = tokenCount[0]
        const isRateLimited = currentUsage > limit

        if (isRateLimited) {
          reject(new Error("Rate limit exceeded"))
        } else {
          resolve()
        }
      }),
  }
}

4. Secure Headers

// next.config.js
const securityHeaders = [
  {
    key: "X-DNS-Prefetch-Control",
    value: "on",
  },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains; preload",
  },
  {
    key: "X-Content-Type-Options",
    value: "nosniff",
  },
  {
    key: "Referrer-Policy",
    value: "origin-when-cross-origin",
  },
]

module.exports = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: securityHeaders,
      },
    ]
  },
}

5. Password Hashing

Always hash passwords with bcrypt or argon2:

import bcrypt from "bcryptjs"

// Register
const hashedPassword = await bcrypt.hash(password, 12)

// Login
const isValid = await bcrypt.compare(password, user.password)

Conclusion

NextAuth.js v5 is a significant upgrade from the previous version. With unified configuration, better TypeScript support, and edge compatibility, implementing authentication becomes more straightforward.

Key takeaways:

  • Use a single auth.ts file for all configuration
  • Leverage middleware for route protection
  • Choose the appropriate session strategy (JWT for stateless, database for more control)
  • Always implement proper security practices
  • Extend types for better TypeScript support

For new projects, NextAuth.js v5 is highly recommended. If you’re still on v4, consider migrating as v5 is now stable and offers better performance.

Happy coding!