
Nextjs Authentication
- 21 installs
- 318 repo stars
- Updated June 22, 2026
- giuseppe-trisciuoglio/developer-kit-claude-code
This is a copy of nextjs-authentication by giuseppe-trisciuoglio - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
nextjs-authentication is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- nextjs-authentication
- AI & Agent Building
- AI-coding skill
Nextjs Authentication by the numbers
- 21 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit-claude-code --skill nextjs-authenticationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 21 |
|---|---|
| repo stars | ★ 318 |
| Last updated | June 22, 2026 |
| Repository | giuseppe-trisciuoglio/developer-kit-claude-code ↗ |
What it does
Helps with ai & agent building tasks.
Files
Next.js Authentication
Overview
Provides authentication implementation patterns for Next.js 15+ App Router using Auth.js 5 (NextAuth.js), covering the complete authentication lifecycle from initial setup to production-ready role-based access control implementations.
When to Use
- Setting up Auth.js 5 from scratch or adding OAuth providers
- Implementing protected routes with Middleware
- Handling authentication in Server Components and Server Actions
- Implementing role-based access control (RBAC)
- Creating credential-based or OAuth sign-in/sign-out flows
Instructions
1. Install Dependencies
Install Auth.js v5 (beta) for Next.js App Router:
npm install next-auth@beta2. Configure Environment Variables
Create .env.local with required variables:
# Required for Auth.js
AUTH_SECRET="your-secret-key-here"
AUTH_URL="http://localhost:3000"
# OAuth Providers (add as needed)
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"Generate AUTH_SECRET with:
openssl rand -base64 323. Create Auth Configuration
Create auth.ts in the project root with providers and callbacks:
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
}
return session;
},
},
pages: {
signIn: "/login",
error: "/error",
},
});4. Create API Route Handler
Create app/api/auth/[...nextauth]/route.ts:
export { GET, POST } from "@/auth";5. Add Middleware for Route Protection
Create middleware.ts in the project root:
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
const isPublicRoute = ["/", "/login", "/register"].includes(nextUrl.pathname);
const isProtectedRoute = nextUrl.pathname.startsWith("/dashboard");
if (isApiAuthRoute) return NextResponse.next();
if (!isLoggedIn && isProtectedRoute) {
return NextResponse.redirect(new URL("/login", nextUrl));
}
if (isLoggedIn && nextUrl.pathname === "/login") {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)"],
};6. Access Session in Server Components
Use the auth() function to access session in Server Components:
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>
</div>
);
}7. Secure Server Actions
Always verify authentication in Server Actions before mutations:
"use server";
import { auth } from "@/auth";
export async function createTodo(formData: FormData) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
// Proceed with protected action
const title = formData.get("title") as string;
await db.todo.create({
data: { title, userId: session.user.id },
});
}8. Handle Sign-In/Sign-Out
Create a login page with server action:
// app/login/page.tsx
import { signIn } from "@/auth";
import { redirect } from "next/navigation";
export default function LoginPage() {
async function handleLogin(formData: FormData) {
"use server";
const result = await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirect: false,
});
if (result?.error) {
return { error: "Invalid credentials" };
}
redirect("/dashboard");
}
return (
<form action={handleLogin}>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Sign In</button>
</form>
);
}For client-side sign-out:
"use client";
import { signOut } from "next-auth/react";
export function SignOutButton() {
return <button onClick={() => signOut()}>Sign Out</button>;
}9. Implement Role-Based Access
Check roles in Server Components:
import { auth } from "@/auth";
import { unauthorized } from "next/navigation";
export default async function AdminPage() {
const session = await auth();
if (session?.user?.role !== "admin") {
unauthorized();
}
return <AdminDashboard />;
}10. Extend TypeScript Types
Create types/next-auth.d.ts for type-safe sessions:
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "user" | "admin";
} & DefaultSession["user"];
}
interface User {
role?: "user" | "admin";
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
role?: "user" | "admin";
}
}Examples
Example 1: Complete Protected Dashboard
Input: User needs a dashboard accessible only to authenticated users
Implementation:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { getUserTodos } from "@/app/lib/data";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
const todos = await getUserTodos(session.user.id);
return (
<main>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
<TodoList todos={todos} />
</main>
);
}Output: Dashboard renders only for authenticated users, with their specific data.
Example 2: Role-Based Admin Panel
Input: Admin panel should be accessible only to users with "admin" role
Implementation:
// app/admin/page.tsx
import { auth } from "@/auth";
import { unauthorized } from "next/navigation";
export default async function AdminPage() {
const session = await auth();
if (session?.user?.role !== "admin") {
unauthorized();
}
return (
<main>
<h1>Admin Panel</h1>
<p>Welcome, administrator {session.user.name}</p>
</main>
);
}Output: Only admin users see the panel; others get 401 error.
Example 3: Secure Server Action with Form
Input: Form submission should only work for authenticated users
Implementation:
// app/components/create-todo-form.tsx
"use server";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const title = formData.get("title") as string;
await db.todo.create({
data: {
title,
userId: session.user.id,
},
});
revalidatePath("/dashboard");
}
// Usage in component
export function CreateTodoForm() {
return (
<form action={createTodo}>
<input name="title" placeholder="New todo..." required />
<button type="submit">Add Todo</button>
</form>
);
}Output: Todo created only for authenticated user; unauthorized requests throw error.
Best Practices
1. Use Server Components by default - Access session directly without client-side JavaScript 2. Minimize Client Components - Only use useSession() for reactive session updates 3. Cache session checks - Use React's cache() for repeated lookups in the same render 4. Middleware for optimistic checks - Redirect quickly, but always re-verify in Server Actions 5. Treat Server Actions like API endpoints - Always authenticate before mutations 6. Never hardcode secrets - Use environment variables for all credentials 7. Implement proper error handling - Return appropriate HTTP status codes 8. Use TypeScript type extensions - Extend NextAuth types for custom fields 9. Separate auth logic - Create a DAL (Data Access Layer) for consistent checks 10. Test authentication flows - Mock auth() function in unit tests
Constraints and Warnings
Critical Limitations
- Middleware runs on Edge runtime - Cannot use Node.js APIs like database drivers
- Server Components cannot set cookies - Use Server Actions for cookie operations
- Session callback timing - Only called on session creation/access, not every request
Common Mistakes
// ❌ WRONG: Setting cookies in Server Component
export default async function Page() {
cookies().set("key", "value"); // Won't work
}
// ✅ CORRECT: Use Server Action
async function setCookieAction() {
"use server";
cookies().set("key", "value");
}// ❌ WRONG: Database queries in Middleware
export default auth(async (req) => {
const user = await db.user.findUnique(); // Won't work in Edge
});
// ✅ CORRECT: Use only Edge-compatible APIs
export default auth(async (req) => {
const session = req.auth; // This works
});Security Considerations
- Always verify authentication in Server Actions - middleware alone is not enough
- Use
unauthorized()for unauthenticated access,redirect()for other cases - Store sensitive tokens in
httpOnlycookies - Validate all user input before processing
- Use HTTPS in production
- Set appropriate cookie
sameSiteattributes
References
- references/authjs-setup.md - Complete Auth.js 5 setup guide with Prisma/Drizzle adapters
- references/oauth-providers.md - Provider-specific configurations (GitHub, Google, Discord, Auth0, etc.)
- references/database-adapter.md - Database session management with Prisma, Drizzle, and custom adapters
- references/testing-patterns.md - Testing authentication flows with Vitest and Playwright
Auth.js 5 Setup Guide
Complete setup guide for Auth.js 5 with Next.js App Router.
Installation
npm install next-auth@beta
# or specific version
npm install @auth/nextjs@latestProject Structure
my-app/
├── auth.ts # Main auth configuration
├── middleware.ts # Route protection
├── types/
│ └── next-auth.d.ts # TypeScript type extensions
├── app/
│ ├── api/
│ │ └── auth/
│ │ └── [...nextauth]/ # API route handler
│ │ └── route.ts
│ ├── login/
│ │ └── page.tsx # Custom login page
│ ├── dashboard/
│ │ └── page.tsx # Protected page
│ └── layout.tsx # Root layout with SessionProvider
├── components/
│ └── auth/
│ ├── sign-in-button.tsx
│ └── user-avatar.tsx
└── lib/
├── auth.ts # Auth utilities
└── dal.ts # Data Access LayerComplete Configuration
1. TypeScript Types
// types/next-auth.d.ts
import { DefaultSession, DefaultUser } from "next-auth";
import { JWT } from "next-auth/jwt";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "user" | "admin" | "moderator";
permissions: string[];
} & DefaultSession["user"];
}
interface User extends DefaultUser {
role?: "user" | "admin" | "moderator";
permissions?: string[];
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
role?: "user" | "admin" | "moderator";
permissions?: string[];
}
}2. Auth Configuration
// auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
// Providers
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
// Database adapter (optional - for database sessions)
adapter: PrismaAdapter(prisma),
// Session configuration
session: {
strategy: "jwt", // or "database"
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // 24 hours
},
// Cookie configuration
cookies: {
sessionToken: {
name: `__Secure-next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
},
},
},
// Authentication providers
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
allowDangerousEmailAccountLinking: true,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
allowDangerousEmailAccountLinking: true,
}),
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 prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) {
return null;
}
const isPasswordValid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
};
},
}),
],
// Callbacks for customizing behavior
callbacks: {
async signIn({ user, account, profile }) {
// Allow OAuth sign-ins without restrictions
if (account?.provider !== "credentials") {
return true;
}
// Additional validation for credentials provider
return true;
},
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same origin
else if (new URL(url).origin === baseUrl) return url;
return baseUrl;
},
async jwt({ token, user, account, trigger, session }) {
// Persist user data to token
if (user) {
token.id = user.id;
token.role = user.role;
token.permissions = user.permissions;
}
// Handle session updates
if (trigger === "update" && session) {
token.name = session.name;
token.image = session.image;
}
return token;
},
async session({ session, token }) {
// Send token data to session
if (token) {
session.user.id = token.id as string;
session.user.role = token.role as "user" | "admin" | "moderator";
session.user.permissions = token.permissions as string[];
}
return session;
},
},
// Custom pages
pages: {
signIn: "/login",
signOut: "/logout",
error: "/error", // Error code passed in query string as ?error=
verifyRequest: "/verify-email", // (used for check email message)
newUser: "/welcome", // New users will be directed here on first sign in
},
// Events for logging or side effects
events: {
async signIn(message) {
console.log("User signed in:", message.user.email);
},
async signOut(message) {
console.log("User signed out:", message.token.email);
},
async createUser(message) {
console.log("New user created:", message.user.email);
},
},
// Debug mode (only in development)
debug: process.env.NODE_ENV === "development",
});3. API Route Handler
// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth";4. Middleware Configuration
// middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
// Define route groups
const publicRoutes = ["/", "/login", "/register", "/forgot-password"];
const authRoutes = ["/login", "/register"];
const protectedRoutes = ["/dashboard", "/profile", "/settings"];
const adminRoutes = ["/admin"];
const apiAuthPrefix = "/api/auth";
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const pathname = nextUrl.pathname;
// Allow all API auth routes
if (pathname.startsWith(apiAuthPrefix)) {
return NextResponse.next();
}
// Check if route is public
const isPublicRoute = publicRoutes.some(
(route) => pathname === route || pathname.startsWith(`${route}/`)
);
// Check if route requires authentication
const isProtectedRoute = protectedRoutes.some(
(route) => pathname === route || pathname.startsWith(`${route}/`)
);
// Check if route requires admin role
const isAdminRoute = adminRoutes.some(
(route) => pathname === route || pathname.startsWith(`${route}/`)
);
// Check if it's an auth route (login/register)
const isAuthRoute = authRoutes.includes(pathname);
// Redirect logged-in users away from auth pages
if (isLoggedIn && isAuthRoute) {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
// Redirect unauthenticated users to login
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(pathname);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackUrl}`, nextUrl)
);
}
// Check admin access
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: [
// Skip static files and images
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};5. Data Access Layer
// lib/dal.ts
import { auth } from "@/auth";
import { cache } from "react";
import { redirect } from "next/navigation";
/**
* Verify current session - cached for same render
*/
export const verifySession = cache(async () => {
const session = await auth();
if (!session?.user) {
return null;
}
return session;
});
/**
* Require authentication - redirects to login if not authenticated
*/
export async function requireAuth() {
const session = await verifySession();
if (!session) {
redirect("/login");
}
return session;
}
/**
* Require specific role
*/
export async function requireRole(role: string) {
const session = await verifySession();
if (!session) {
redirect("/login");
}
if (session.user.role !== role) {
redirect("/unauthorized");
}
return session;
}
/**
* Get current user with data
*/
export async function getCurrentUser() {
const session = await requireAuth();
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
email: true,
name: true,
image: true,
role: true,
createdAt: true,
},
});
if (!user) {
throw new Error("User not found");
}
return user;
}6. Auth Utilities
// lib/auth.ts
import bcrypt from "bcryptjs";
/**
* Hash a password
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
/**
* Verify a password
*/
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
/**
* Generate a secure random token
*/
export function generateToken(length: number = 32): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}Environment Variables
# .env.local
# NextAuth.js
AUTH_SECRET="your-secret-key-here" # Generate with: openssl rand -base64 32
AUTH_URL="http://localhost:3000"
# OAuth Providers
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# Database (if using database sessions)
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
# Email (if using email provider)
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
SMTP_USER="your-email@gmail.com"
SMTP_PASSWORD="your-app-password"
FROM_EMAIL="noreply@yourapp.com"Client Component Setup
For client components that need session access:
// components/providers/session-provider.tsx
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { ReactNode } from "react";
export function SessionProvider({ children }: { children: ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}// app/layout.tsx
import { SessionProvider } from "@/components/providers/session-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}Best Practices
Comprehensive best practices for Next.js authentication with Auth.js 5.
Best Practices
1. Use Server Components by default - Access session directly without client-side JavaScript 2. Minimize Client Components - Only use useSession() for reactive session updates 3. Cache session checks - Use React's cache() for repeated lookups in the same render 4. Middleware for optimistic checks - Redirect quickly, but always re-verify in Server Actions 5. Treat Server Actions like API endpoints - Always authenticate before mutations 6. Never hardcode secrets - Use environment variables for all credentials 7. Implement proper error handling - Return appropriate HTTP status codes 8. Use TypeScript type extensions - Extend NextAuth types for custom fields 9. Separate auth logic - Create a DAL (Data Access Layer) for consistent checks 10. Test authentication flows - Mock auth() function in unit tests
Constraints and Warnings
Critical Limitations
- Middleware runs on Edge runtime - Cannot use Node.js APIs like database drivers
- Server Components cannot set cookies - Use Server Actions for cookie operations
- Session callback timing - Only called on session creation/access, not every request
Common Mistakes
// ❌ WRONG: Setting cookies in Server Component
export default async function Page() {
cookies().set("key", "value"); // Won't work
}
// ✅ CORRECT: Use Server Action
async function setCookieAction() {
"use server";
cookies().set("key", "value");
}// ❌ WRONG: Database queries in Middleware
export default auth(async (req) => {
const user = await db.user.findUnique(); // Won't work in Edge
});
// ✅ CORRECT: Use only Edge-compatible APIs
export default auth(async (req) => {
const session = req.auth; // This works
});Security Considerations
- Always verify authentication in Server Actions - middleware alone is not enough
- Use
unauthorized()for unauthenticated access,redirect()for other cases - Store sensitive tokens in
httpOnlycookies - Validate all user input before processing
- Use HTTPS in production
- Set appropriate cookie
sameSiteattributes
Database Adapter Configuration
Complete guide for using database sessions with Auth.js 5 and Next.js App Router.
Prisma Setup
1. Install Dependencies
npm install @auth/prisma-adapter prisma @prisma/client
npm install -D prisma2. Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or "mysql" | "sqlite"
url = env("DATABASE_URL")
}
// NextAuth.js models
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
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])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime? @map("email_verified")
image String?
password String? // For credentials provider
role String @default("user")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
accounts Account[]
sessions Session[]
@@map("users")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verificationtokens")
}3. Generate Prisma Client
npx prisma generate
npx prisma db push4. 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;5. Auth Configuration with Prisma Adapter
// auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: {
strategy: "database", // Use database sessions
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // 24 hours
},
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
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 prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
};
},
}),
],
callbacks: {
async session({ session, user }) {
// With database sessions, user is the database user
if (session.user) {
session.user.id = user.id;
session.user.role = (user as any).role;
}
return session;
},
},
});Drizzle ORM Setup
1. Install Dependencies
npm install @auth/drizzle-adapter drizzle-orm
npm install -D drizzle-kit2. Database Schema
// lib/db/schema.ts
import {
pgTable,
text,
timestamp,
primaryKey,
integer,
uuid,
} from "drizzle-orm/pg-core";
import type { AdapterAccount } from "next-auth/adapters";
export const users = pgTable("user", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
password: text("password"),
role: text("role").default("user"),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
});
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").notNull().primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
);3. Drizzle Client
// lib/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });4. Auth Configuration with Drizzle Adapter
// auth.ts
import NextAuth from "next-auth";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema";
import GitHub from "next-auth/providers/github";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db, {
usersTable: schema.users,
accountsTable: schema.accounts,
sessionsTable: schema.sessions,
verificationTokensTable: schema.verificationTokens,
}),
session: {
strategy: "database",
},
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
});Custom Adapter
For custom database or cache implementations:
// lib/auth/custom-adapter.ts
import type {
Adapter,
AdapterUser,
AdapterAccount,
AdapterSession,
VerificationToken,
} from "next-auth/adapters";
export function CustomAdapter(db: any): Adapter {
return {
async createUser(user) {
const created = await db.user.create({
data: {
email: user.email,
name: user.name,
image: user.image,
emailVerified: user.emailVerified,
},
});
return created;
},
async getUser(id) {
return await db.user.findUnique({ where: { id } });
},
async getUserByEmail(email) {
return await db.user.findUnique({ where: { email } });
},
async getUserByAccount({ providerAccountId, provider }) {
const account = await db.account.findFirst({
where: { providerAccountId, provider },
include: { user: true },
});
return account?.user ?? null;
},
async updateUser(user) {
return await db.user.update({
where: { id: user.id },
data: user,
});
},
async linkAccount(account) {
await db.account.create({
data: {
userId: account.userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
refresh_token: account.refresh_token,
access_token: account.access_token,
expires_at: account.expires_at,
token_type: account.token_type,
scope: account.scope,
id_token: account.id_token,
session_state: account.session_state,
},
});
return account;
},
async createSession(session) {
return await db.session.create({
data: {
sessionToken: session.sessionToken,
userId: session.userId,
expires: session.expires,
},
});
},
async getSessionAndUser(sessionToken) {
const session = await db.session.findUnique({
where: { sessionToken },
include: { user: true },
});
if (!session) return null;
return {
session: {
sessionToken: session.sessionToken,
userId: session.userId,
expires: session.expires,
},
user: session.user,
};
},
async updateSession(session) {
return await db.session.update({
where: { sessionToken: session.sessionToken },
data: session,
});
},
async deleteSession(sessionToken) {
await db.session.delete({ where: { sessionToken } });
},
async createVerificationToken(token) {
await db.verificationToken.create({
data: {
identifier: token.identifier,
token: token.token,
expires: token.expires,
},
});
return token;
},
async useVerificationToken({ identifier, token }) {
try {
const deleted = await db.verificationToken.delete({
where: {
identifier_token: { identifier, token },
},
});
return deleted;
} catch {
return null;
}
},
};
}User Registration with Database
// app/actions/register.ts
"use server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function registerUser(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const name = formData.get("name") as string;
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return { error: "User already exists" };
}
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name,
},
});
redirect("/login");
}Session Management
List User Sessions
// app/actions/sessions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function getUserSessions() {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const sessions = await prisma.session.findMany({
where: { userId: session.user.id },
orderBy: { expires: "desc" },
});
return sessions;
}
export async function revokeSession(sessionToken: string) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
// Verify the session belongs to the user
const targetSession = await prisma.session.findUnique({
where: { sessionToken },
});
if (targetSession?.userId !== session.user.id) {
throw new Error("Unauthorized");
}
await prisma.session.delete({
where: { sessionToken },
});
return { success: true };
}Environment Variables
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
# or
DATABASE_URL="mysql://user:password@localhost:3306/mydb"
# or
DATABASE_URL="file:./dev.db" # SQLite
# NextAuth
AUTH_SECRET="your-secret-key"
AUTH_URL="http://localhost:3000"
# OAuth Providers
GITHUB_ID="your-github-id"
GITHUB_SECRET="your-github-secret"Examples
This document contains comprehensive code examples for Next.js authentication patterns.
Example 1: Complete Protected Dashboard
Input: User needs a dashboard accessible only to authenticated users
Implementation:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { getUserTodos } from "@/app/lib/data";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
const todos = await getUserTodos(session.user.id);
return (
<main>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
<TodoList todos={todos} />
</main>
);
}Output: Dashboard renders only for authenticated users, with their specific data.
Example 2: Role-Based Admin Panel
Input: Admin panel should be accessible only to users with "admin" role
Implementation:
// app/admin/page.tsx
import { auth } from "@/auth";
import { unauthorized } from "next/navigation";
export default async function AdminPage() {
const session = await auth();
if (session?.user?.role !== "admin") {
unauthorized();
}
return (
<main>
<h1>Admin Panel</h1>
<p>Welcome, administrator {session.user.name}</p>
</main>
);
}Output: Only admin users see the panel; others get 401 error.
Example 3: Secure Server Action with Form
Input: Form submission should only work for authenticated users
Implementation:
// app/components/create-todo-form.tsx
"use server";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const title = formData.get("title") as string;
await db.todo.create({
data: {
title,
userId: session.user.id,
},
});
revalidatePath("/dashboard");
}
// Usage in component
export function CreateTodoForm() {
return (
<form action={createTodo}>
<input name="title" placeholder="New todo..." required />
<button type="submit">Add Todo</button>
</form>
);
}Output: Todo created only for authenticated user; unauthorized requests throw error.
Example 4: OAuth Sign-In Button
Input: User should be able to sign in with GitHub
Implementation:
// components/auth/sign-in-button.tsx
"use client";
import { signIn, signOut, useSession } from "next-auth/react";
export function AuthButton() {
const { data: session, status } = useSession();
if (status === "loading") {
return <button disabled>Loading...</button>;
}
if (session) {
return (
<button onClick={() => signOut()}>
Sign out {session.user?.name}
</button>
);
}
return (
<button onClick={() => signIn("github")}>
Sign in with GitHub
</button>
);
}Output: Button shows "Sign in with GitHub" for unauthenticated users, "Sign out {name}" for authenticated users.
Example 5: Credentials Provider Login
Input: Implement email/password login
Implementation:
// auth.ts
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
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 db.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.password) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password,
user.password
);
return isValid
? { id: user.id, email: user.email, name: user.name }
: null;
},
}),
],
});Output: Users can authenticate with email/password against your database.
OAuth Provider Configurations
Configuration examples for popular OAuth providers with Auth.js 5.
GitHub
Setup
1. Go to GitHub Settings → Developer Settings → OAuth Apps → New OAuth App 2. Set Homepage URL: http://localhost:3000 (development) 3. Set Authorization callback URL: http://localhost:3000/api/auth/callback/github
Configuration
// auth.ts
import GitHub from "next-auth/providers/github";
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
// Allow linking accounts with same email
allowDangerousEmailAccountLinking: true,
}),
]Environment Variables
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"Custom Profile Data
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
profile(profile) {
return {
id: profile.id.toString(),
name: profile.name || profile.login,
email: profile.email,
image: profile.avatar_url,
// Custom fields
username: profile.login,
githubUrl: profile.html_url,
};
},
})Setup
1. Go to Google Cloud Console → APIs & Services → Credentials 2. Create OAuth 2.0 Client ID 3. Add authorized redirect URI: http://localhost:3000/api/auth/callback/google
Configuration
// auth.ts
import Google from "next-auth/providers/google";
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
allowDangerousEmailAccountLinking: true,
// Request additional scopes
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code",
scope: [
"openid",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/calendar.readonly",
].join(" "),
},
},
}),
]Environment Variables
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"Access Google APIs
// In callbacks
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
}
// Refresh token if expired
if (token.expiresAt && Date.now() > (token.expiresAt as number) * 1000) {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}),
});
const tokens = await response.json();
token.accessToken = tokens.access_token;
token.expiresAt = Math.floor(Date.now() / 1000 + tokens.expires_in);
}
return token;
},Discord
Configuration
import Discord from "next-auth/providers/discord";
providers: [
Discord({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
authorization: {
params: {
scope: "identify email guilds",
},
},
profile(profile) {
return {
id: profile.id,
name: profile.global_name || profile.username,
email: profile.email,
image: profile.avatar
? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
: null,
discordId: profile.id,
discriminator: profile.discriminator,
};
},
}),
]Auth0
Configuration
import Auth0 from "next-auth/providers/auth0";
providers: [
Auth0({
clientId: process.env.AUTH0_CLIENT_ID!,
clientSecret: process.env.AUTH0_CLIENT_SECRET!,
issuer: process.env.AUTH0_ISSUER!,
authorization: {
params: {
audience: process.env.AUTH0_AUDIENCE,
},
},
}),
]Environment Variables
AUTH0_CLIENT_ID="your-auth0-client-id"
AUTH0_CLIENT_SECRET="your-auth0-client-secret"
AUTH0_ISSUER="https://your-domain.auth0.com"
AUTH0_AUDIENCE="https://your-api-identifier"Microsoft Entra ID (Azure AD)
Configuration
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
providers: [
MicrosoftEntraID({
clientId: process.env.AZURE_AD_CLIENT_ID!,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
tenantId: process.env.AZURE_AD_TENANT_ID!,
authorization: {
params: {
scope: "openid profile email User.Read",
},
},
}),
]Environment Variables
AZURE_AD_CLIENT_ID="your-azure-client-id"
AZURE_AD_CLIENT_SECRET="your-azure-client-secret"
AZURE_AD_TENANT_ID="your-azure-tenant-id"Apple
Configuration
import Apple from "next-auth/providers/apple";
providers: [
Apple({
clientId: process.env.APPLE_ID!,
clientSecret: {
appleId: process.env.APPLE_ID!,
teamId: process.env.APPLE_TEAM_ID!,
privateKey: process.env.APPLE_PRIVATE_KEY!,
keyId: process.env.APPLE_KEY_ID!,
},
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name
? `${profile.name.firstName} ${profile.name.lastName}`
: null,
email: profile.email,
image: null,
};
},
}),
]Environment Variables
APPLE_ID="your-service-id"
APPLE_TEAM_ID="your-team-id"
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----"
APPLE_KEY_ID="your-key-id"Configuration
import LinkedIn from "next-auth/providers/linkedin";
providers: [
LinkedIn({
clientId: process.env.LINKEDIN_CLIENT_ID!,
clientSecret: process.env.LINKEDIN_CLIENT_SECRET!,
authorization: {
params: {
scope: "openid profile email",
},
},
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
};
},
}),
]Twitter (X)
Configuration
import Twitter from "next-auth/providers/twitter";
providers: [
Twitter({
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
version: "2.0", // OAuth 2.0
profile(profile) {
return {
id: profile.data.id,
name: profile.data.name,
email: profile.data.email,
image: profile.data.profile_image_url?.replace("_normal", ""),
username: profile.data.username,
};
},
}),
]Multiple Providers with Account Linking
// auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Discord from "next-auth/providers/discord";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
allowDangerousEmailAccountLinking: true,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
allowDangerousEmailAccountLinking: true,
}),
Discord({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
allowDangerousEmailAccountLinking: true,
}),
],
callbacks: {
async signIn({ user, account, profile, email, credentials }) {
// Allow all OAuth sign-ins
if (account?.provider !== "credentials") {
return true;
}
// Additional validation for credentials
return true;
},
},
});Provider-Specific UI Components
Social Login Buttons
// components/auth/social-login.tsx
"use client";
import { signIn } from "next-auth/react";
import { Github, Chrome } from "lucide-react";
export function SocialLogin() {
return (
<div className="grid gap-2">
<button
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
className="flex items-center justify-center gap-2 rounded-lg border p-2 hover:bg-gray-50"
>
<Github className="h-5 w-5" />
Continue with GitHub
</button>
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className="flex items-center justify-center gap-2 rounded-lg border p-2 hover:bg-gray-50"
>
<Chrome className="h-5 w-5" />
Continue with Google
</button>
</div>
);
}Login Page with OAuth
// app/login/page.tsx
import { SocialLogin } from "@/components/auth/social-login";
import { CredentialsForm } from "@/components/auth/credentials-form";
export default function LoginPage() {
return (
<div className="mx-auto max-w-sm space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Sign In</h1>
<p className="text-gray-500">Choose your sign-in method</p>
</div>
<SocialLogin />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<CredentialsForm />
</div>
);
}Testing Authentication Patterns
Testing strategies for Next.js authentication with Vitest and Playwright.
Unit Testing with Vitest
Setup
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./tests/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
});// tests/setup.ts
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock next/navigation
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
// Mock next/headers
vi.mock("next/headers", () => ({
cookies: () => ({
get: vi.fn(),
set: vi.fn(),
}),
headers: () => new Headers(),
}));Testing Auth Module
// tests/auth/auth.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { auth } from "@/auth";
vi.mock("@/auth", () => ({
auth: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
}));
describe("Authentication", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Session Verification", () => {
it("should return null when user is not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
const session = await auth();
expect(session).toBeNull();
});
it("should return session when user is authenticated", async () => {
const mockSession = {
user: {
id: "user-1",
email: "test@example.com",
name: "Test User",
role: "user",
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
vi.mocked(auth).mockResolvedValue(mockSession);
const session = await auth();
expect(session).toEqual(mockSession);
expect(session?.user?.id).toBe("user-1");
});
});
describe("Role-Based Access", () => {
it("should identify admin users", async () => {
const adminSession = {
user: {
id: "admin-1",
email: "admin@example.com",
role: "admin",
},
};
vi.mocked(auth).mockResolvedValue(adminSession);
const session = await auth();
expect(session?.user?.role).toBe("admin");
});
});
});Testing DAL Functions
// tests/auth/dal.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { verifySession, requireAuth, requireRole } from "@/lib/dal";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
vi.mock("@/auth", () => ({
auth: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("Data Access Layer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("verifySession", () => {
it("should return session for authenticated user", async () => {
const mockSession = {
user: { id: "1", email: "test@example.com", role: "user" },
};
vi.mocked(auth).mockResolvedValue(mockSession);
const result = await verifySession();
expect(result).toEqual(mockSession);
});
it("should return null for unauthenticated user", async () => {
vi.mocked(auth).mockResolvedValue(null);
const result = await verifySession();
expect(result).toBeNull();
});
});
describe("requireAuth", () => {
it("should return session when authenticated", async () => {
const mockSession = {
user: { id: "1", email: "test@example.com" },
};
vi.mocked(auth).mockResolvedValue(mockSession);
const result = await requireAuth();
expect(result).toEqual(mockSession);
});
it("should redirect to login when not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
await requireAuth();
expect(redirect).toHaveBeenCalledWith("/login");
});
});
describe("requireRole", () => {
it("should return session for correct role", async () => {
const mockSession = {
user: { id: "1", email: "admin@example.com", role: "admin" },
};
vi.mocked(auth).mockResolvedValue(mockSession);
const result = await requireRole("admin");
expect(result).toEqual(mockSession);
});
it("should redirect to unauthorized for wrong role", async () => {
const mockSession = {
user: { id: "1", email: "user@example.com", role: "user" },
};
vi.mocked(auth).mockResolvedValue(mockSession);
await requireRole("admin");
expect(redirect).toHaveBeenCalledWith("/unauthorized");
});
});
});Testing Server Actions
// tests/actions/todo.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createTodo } from "@/app/actions/todo";
import { auth } from "@/auth";
vi.mock("@/auth", () => ({
auth: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: {
todo: {
create: vi.fn(),
},
},
}));
describe("Todo Actions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createTodo", () => {
it("should throw error when user is not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
const formData = new FormData();
formData.append("title", "Test Todo");
await expect(createTodo(formData)).rejects.toThrow("Unauthorized");
});
it("should create todo for authenticated user", async () => {
const mockSession = {
user: { id: "user-1", email: "test@example.com", role: "user" },
};
vi.mocked(auth).mockResolvedValue(mockSession);
const formData = new FormData();
formData.append("title", "Test Todo");
const { db } = await import("@/lib/db");
vi.mocked(db.todo.create).mockResolvedValue({
id: "1",
title: "Test Todo",
userId: "user-1",
});
await createTodo(formData);
expect(db.todo.create).toHaveBeenCalledWith({
data: {
title: "Test Todo",
userId: "user-1",
},
});
});
});
});Testing React Components
// tests/components/sign-in-button.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { SignInButton } from "@/components/auth/sign-in-button";
import { useSession, signIn, signOut } from "next-auth/react";
vi.mock("next-auth/react", () => ({
useSession: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
}));
describe("SignInButton", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should show loading state", () => {
vi.mocked(useSession).mockReturnValue({
data: null,
status: "loading",
update: vi.fn(),
});
render(<SignInButton />);
expect(screen.getByText("Loading...")).toBeDisabled();
});
it("should show sign in button when unauthenticated", () => {
vi.mocked(useSession).mockReturnValue({
data: null,
status: "unauthenticated",
update: vi.fn(),
});
render(<SignInButton />);
const button = screen.getByText("Sign in with GitHub");
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(signIn).toHaveBeenCalledWith("github");
});
it("should show sign out button when authenticated", () => {
vi.mocked(useSession).mockReturnValue({
data: {
user: { name: "John Doe", email: "john@example.com" },
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
status: "authenticated",
update: vi.fn(),
});
render(<SignInButton />);
const button = screen.getByText("Sign out John Doe");
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(signOut).toHaveBeenCalled();
});
});Integration Testing
Testing API Routes
// tests/api/auth.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "@/app/api/user/route";
import { auth } from "@/auth";
vi.mock("@/auth", () => ({
auth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: {
user: {
findUnique: vi.fn(),
},
},
}));
describe("API Routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/user", () => {
it("should return 401 when not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
const response = await GET();
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: "Unauthorized" });
});
it("should return user data when authenticated", async () => {
const mockSession = {
user: { id: "user-1", email: "test@example.com" },
};
vi.mocked(auth).mockResolvedValue(mockSession);
const mockUser = {
id: "user-1",
name: "Test User",
email: "test@example.com",
};
const { db } = await import("@/lib/db");
vi.mocked(db.user.findUnique).mockResolvedValue(mockUser);
const response = await GET();
expect(response.status).toBe(200);
expect(await response.json()).toEqual(mockUser);
});
});
});E2E Testing with Playwright
Setup
npm install -D @playwright/test
npx playwright install// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});Auth Fixtures
// e2e/fixtures.ts
import { test as base, expect } from "@playwright/test";
export * from "@playwright/test";
export const test = base.extend<{
login: (user?: { email: string; password: string }) => Promise<void>;
}>({
login: async ({ page }, use) => {
await use(async (user) => {
await page.goto("/login");
const email = user?.email || "test@example.com";
const password = user?.password || "password123";
await page.fill('input[name="email"]', email);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL("/dashboard");
});
},
});Auth Flow Tests
// e2e/auth.spec.ts
import { test, expect } from "./fixtures";
test.describe("Authentication Flows", () => {
test("should redirect to login when accessing protected page", async ({
page,
}) => {
await page.goto("/dashboard");
await expect(page).toHaveURL("/login?callbackUrl=%2Fdashboard");
});
test("should login with credentials", async ({ page }) => {
await page.goto("/login");
await page.fill('input[name="email"]', "test@example.com");
await page.fill('input[name="password"]', "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("h1")).toContainText("Dashboard");
});
test("should show error for invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.fill('input[name="email"]', "wrong@example.com");
await page.fill('input[name="password"]', "wrongpassword");
await page.click('button[type="submit"]');
await expect(page.locator("text=Invalid credentials")).toBeVisible();
});
test("should logout user", async ({ page, login }) => {
await login();
await page.click("text=Sign out");
await expect(page).toHaveURL("/");
await page.goto("/dashboard");
await expect(page).toHaveURL("/login");
});
});
test.describe("Protected Routes", () => {
test("should allow admin to access admin page", async ({ page, login }) => {
// Login as admin
await login({ email: "admin@example.com", password: "admin123" });
await page.goto("/admin");
await expect(page.locator("h1")).toContainText("Admin");
});
test("should redirect non-admin from admin page", async ({ page, login }) => {
// Login as regular user
await login({ email: "user@example.com", password: "user123" });
await page.goto("/admin");
await expect(page).toHaveURL("/unauthorized");
});
});Storage State for Authenticated Tests
// e2e/global-setup.ts
import { chromium, FullConfig } from "@playwright/test";
async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use;
const browser = await chromium.launch();
const page = await browser.newPage();
// Login and save storage state
await page.goto(`${baseURL}/login`);
await page.fill('input[name="email"]', "test@example.com");
await page.fill('input[name="password"]', "password123");
await page.click('button[type="submit"]');
await page.waitForURL(`${baseURL}/dashboard`);
await page.context().storageState({ path: "e2e/.auth/user.json" });
// Admin login
await page.goto(`${baseURL}/login`);
await page.fill('input[name="email"]', "admin@example.com");
await page.fill('input[name="password"]', "admin123");
await page.click('button[type="submit"]');
await page.waitForURL(`${baseURL}/dashboard`);
await page.context().storageState({ path: "e2e/.auth/admin.json" });
await browser.close();
}
export default globalSetup;// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
globalSetup: require.resolve("./e2e/global-setup"),
projects: [
{
name: "authenticated",
use: {
...devices["Desktop Chrome"],
storageState: "e2e/.auth/user.json",
},
},
{
name: "admin",
use: {
...devices["Desktop Chrome"],
storageState: "e2e/.auth/admin.json",
},
},
],
});Mocking Auth in Stories (Storybook)
// .storybook/preview.tsx
import type { Preview } from "@storybook/react";
import { SessionProvider } from "next-auth/react";
const preview: Preview = {
decorators: [
(Story, context) => {
const session = context.parameters.session || null;
return (
<SessionProvider session={session}>
<Story />
</SessionProvider>
);
},
],
};
export default preview;// components/auth/sign-in-button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { SignInButton } from "./sign-in-button";
const meta: Meta<typeof SignInButton> = {
component: SignInButton,
};
export default meta;
type Story = StoryObj<typeof SignInButton>;
export const Unauthenticated: Story = {
parameters: {
session: null,
},
};
export const Authenticated: Story = {
parameters: {
session: {
user: {
name: "John Doe",
email: "john@example.com",
image: "https://example.com/avatar.jpg",
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
},
};
export const Loading: Story = {
parameters: {
session: undefined,
},
};