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

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

Thursday, Dec 25, 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 significant changes that make setup simpler and performance better. Let’s dive in from start to production-ready!

Why NextAuth.js?

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

  • Zero-config for OAuth - Setting up Google, GitHub, and other providers only requires a few lines
  • Built-in session management - No need to build from scratch
  • Database adapters - 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"

Main changes:

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

Setup and Installation

Let’s start from the beginning. Make sure you 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://..."

Configuring auth.ts

Create file auth.ts in project root or 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 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 email and password login:

// 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 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)

Setting up OAuth providers is very straightforward in v5:

// 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,
      authorization: {
        params: {
          prompt: "consent",
          access_type: "offline",
          response_type: "code",
        },
      },
    }),
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
  ],
})

Create OAuth buttons:

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

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

export function OAuthButtons() {
  return (
    <div className="space-y-3">
      <button
        onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
        className="w-full flex items-center justify-center gap-2 bg-white border p-2 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 bg-gray-900 text-white p-2 rounded hover:bg-gray-800"
      >
        <GitHubIcon />
        Continue with GitHub
      </button>
    </div>
  )
}

Database Adapters (Prisma)

To persist user data, we use the Prisma adapter:

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

Setup Prisma schema:

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

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

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[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

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])
}

Integrate with NextAuth:

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

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "jwt" },
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
      }
      return token
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.sub!
        session.user.role = token.role as string
      }
      return session
    },
  },
})

Create Prisma client singleton:

// lib/prisma.ts
import { PrismaClient } from "@prisma/client"

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma = globalForPrisma.prisma ?? new PrismaClient()

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

Session Management

In v5, session management is more flexible:

// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
  session: {
    strategy: "jwt", // or "database"
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // Update every 24 hours
  },
  jwt: {
    maxAge: 30 * 24 * 60 * 60,
  },
  // ...
})

Extend session and JWT types:

// types/next-auth.d.ts
import { DefaultSession, DefaultUser } from "next-auth"
import { JWT, DefaultJWT } from "next-auth/jwt"

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

  interface User extends DefaultUser {
    role: string
  }
}

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

Protecting Routes (Middleware)

Use middleware to protect routes efficiently:

// middleware.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"

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

  const isPublicRoute = ["/", "/login", "/register", "/api/auth"].some(
    (route) => nextUrl.pathname.startsWith(route)
  )
  const isProtectedRoute = nextUrl.pathname.startsWith("/dashboard")
  const isAdminRoute = nextUrl.pathname.startsWith("/admin")

  // Redirect logged-in users away from auth pages
  if (isLoggedIn && (nextUrl.pathname === "/login" || nextUrl.pathname === "/register")) {
    return NextResponse.redirect(new URL("/dashboard", nextUrl))
  }

  // Protect dashboard routes
  if (isProtectedRoute && !isLoggedIn) {
    return NextResponse.redirect(new URL("/login", nextUrl))
  }

  // Protect admin routes
  if (isAdminRoute) {
    if (!isLoggedIn) {
      return NextResponse.redirect(new URL("/login", nextUrl))
    }
    if (req.auth?.user?.role !== "admin") {
      return NextResponse.redirect(new URL("/dashboard", nextUrl))
    }
  }

  return NextResponse.next()
})

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

Client-side Session (useSession)

For accessing session in client components:

// app/providers.tsx
"use client"

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

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

Wrap in layout:

// 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>
  )
}

Use in component:

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

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

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

  if (status === "loading") {
    return <div className="animate-pulse w-8 h-8 bg-gray-200 rounded-full" />
  }

  if (!session) {
    return (
      <Link href="/login" className="text-sm hover:underline">
        Login
      </Link>
    )
  }

  return (
    <div className="flex items-center gap-4">
      <span className="text-sm">{session.user.name}</span>
      <img
        src={session.user.image || "/default-avatar.png"}
        alt="Avatar"
        className="w-8 h-8 rounded-full"
      />
      <button
        onClick={() => signOut({ callbackUrl: "/" })}
        className="text-sm text-red-600 hover:underline"
      >
        Logout
      </button>
    </div>
  )
}

For server components, use auth() directly:

// 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>Welcome, {session.user.name}!</h1>
      <p>Role: {session.user.role}</p>
    </div>
  )
}

Role-based Access Control

Implementing proper RBAC:

// lib/permissions.ts
export type Role = "user" | "admin" | "moderator"

export const permissions = {
  user: ["read:own_profile", "update:own_profile"],
  moderator: [
    "read:own_profile",
    "update:own_profile",
    "read:all_users",
    "moderate:content",
  ],
  admin: [
    "read:own_profile",
    "update:own_profile",
    "read:all_users",
    "moderate:content",
    "manage:users",
    "manage:settings",
  ],
} as const

export function hasPermission(role: Role, permission: string): boolean {
  return permissions[role]?.includes(permission as any) ?? false
}

Create helper component:

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

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

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

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

  if (!allowedRoles.includes(session.user.role)) {
    if (fallback) return <>{fallback}</>
    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>
  )
}

Security Best Practices

Some security tips you must follow:

1. Environment Variables

# DON'T commit to repository!
AUTH_SECRET="use-a-long-random-string"
AUTH_TRUST_HOST=true # Only for production

2. CSRF Protection

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

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. 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 one 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 a highly recommended choice. If you’re still on v4, consider migrating as v5 is stable and performs better.

Happy coding! 🚀