Skip to main content

Authentication with NextAuth v5

NextAuth v5 (Auth.js) handles authentication in Next.js apps. It supports 50+ OAuth providers, credentials (email/password), and database sessions — with first-class App Router support.

Installation

npm install next-auth@beta
npx auth secret # generates AUTH_SECRET in .env

Configuration

// auth.ts (at root — exported and used everywhere)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import { compare } from 'bcrypt';

export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
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 compare(credentials.password as string, user.password);
if (!isValid) return null;

return { id: user.id, name: user.name, email: user.email, role: user.role };
},
}),
],
callbacks: {
// Add custom fields to the JWT token
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role: string }).role;
}
return token;
},
// Add custom fields to the session
async session({ session, token }) {
session.user.id = token.id as string;
session.user.role = token.role as string;
return session;
},
},
pages: {
signIn: '/login',
error: '/login',
},
session: { strategy: 'jwt' },
});

Route Handler

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

Middleware (protect routes)

// middleware.ts (at root)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export default auth((req) => {
const isLoggedIn = !!req.auth;
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard');
const isOnLogin = req.nextUrl.pathname.startsWith('/login');

if (isOnDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.url));
}

if (isOnLogin && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', req.url));
}
});

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

Server Component — Get Session

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

Client Component — useSession

'use client';

import { useSession } from 'next-auth/react';

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

if (status === 'loading') return <Skeleton />;
if (!session) return <a href="/login">Sign In</a>;

return (
<div>
<img src={session.user.image ?? ''} alt={session.user.name ?? ''} />
<span>{session.user.name}</span>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}

Wrap the app with SessionProvider:

// app/providers.tsx
'use client';
import { SessionProvider } from 'next-auth/react';

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

Login Page

// app/login/page.tsx
'use client';

import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';

export default function LoginPage() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') ?? '/dashboard';
const error = searchParams.get('error');

async function handleCredentials(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
callbackUrl,
});
}

return (
<div className="max-w-md mx-auto mt-16 p-8 border rounded-lg">
<h1 className="text-2xl font-bold mb-6">Sign In</h1>

{error && (
<p className="text-red-500 mb-4">Invalid email or password</p>
)}

<form onSubmit={handleCredentials} className="space-y-4">
<input name="email" type="email" placeholder="Email" className="input" required />
<input name="password" type="password" placeholder="Password" className="input" required />
<button type="submit" className="btn-primary w-full">Sign In</button>
</form>

<div className="mt-6 text-center">
<button onClick={() => signIn('github', { callbackUrl })} className="btn-secondary w-full">
Continue with GitHub
</button>
</div>
</div>
);
}

Prisma Schema for Auth

model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String? // null for OAuth users
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?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}