Next.js Authentication with Auth.js (NextAuth v5) (2026)

Auth.js v5 (the library formerly known as NextAuth) is the go-to authentication solution for Next.js App Router applications. It handles OAuth providers, credentials-based login, JWT and database sessions, email magic links, and route protection — all with first-class TypeScript support. This guide builds a complete auth system from scratch including RBAC and protected middleware.

Table of Contents

Installation and auth.ts Configuration

npm install next-auth@beta
# Auth.js v5 is distributed as next-auth@beta as of 2026

# Generate a random secret for signing tokens
openssl rand -base64 32
# .env.local
AUTH_SECRET="your-generated-secret-here"
AUTH_GITHUB_ID="your-github-oauth-app-id"
AUTH_GITHUB_SECRET="your-github-oauth-app-secret"
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
DATABASE_URL="postgresql://user:pass@localhost:5432/myapp"
// auth.ts — place in the project root (alongside package.json)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { z } from 'zod';

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma), // only needed for database sessions
  providers: [
    GitHub,
    Google,
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        const parsed = z.object({
          email: z.string().email(),
          password: z.string().min(8),
        }).safeParse(credentials);

        if (!parsed.success) return null;

        const user = await prisma.user.findUnique({
          where: { email: parsed.data.email },
        });

        if (!user?.hashedPassword) return null;

        const isValid = await bcrypt.compare(parsed.data.password, user.hashedPassword);
        if (!isValid) return null;

        return { id: user.id, email: user.email, name: user.name, role: user.role };
      },
    }),
  ],
  session: { strategy: 'jwt' }, // or 'database' — see sessions section
  callbacks: {
    async jwt({ token, user }) {
      // On initial sign-in, user object is populated — persist role to token
      if (user) {
        token.role = (user as any).role ?? 'user';
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      // Expose token fields on the session object
      if (session.user) {
        session.user.role = token.role as string;
        session.user.id = token.id as string;
      }
      return session;
    },
  },
  pages: {
    signIn: '/login',       // custom login page
    error: '/auth/error',   // auth error page
    verifyRequest: '/auth/verify-email', // magic link sent page
  },
});
// app/api/auth/[...nextauth]/route.ts — App Router handler
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
Note: In Auth.js v5, the configuration lives in a single auth.ts file at the project root — not in pages/api/auth/[...nextauth].ts. The handlers export is dropped into the App Router API route. This is a significant change from NextAuth v4.

OAuth Providers: GitHub and Google

OAuth providers require creating an application on each provider's developer portal. Set the callback URL to http://localhost:3000/api/auth/callback/github (or /google) in development.

// app/login/page.tsx — Custom login page with OAuth buttons
import { signIn } from '@/auth';

export default function LoginPage() {
  return (
    <div className="flex flex-col gap-4 max-w-sm mx-auto mt-16">
      <h1>Sign in</h1>

      {/* GitHub OAuth — server action */}
      <form action={async () => { 'use server'; await signIn('github', { redirectTo: '/dashboard' }); }}>
        <button type="submit" className="btn btn-dark w-100">
          Sign in with GitHub
        </button>
      </form>

      {/* Google OAuth */}
      <form action={async () => { 'use server'; await signIn('google', { redirectTo: '/dashboard' }); }}>
        <button type="submit" className="btn btn-danger w-100">
          Sign in with Google
        </button>
      </form>

      {/* Email/password form links to a client component below */}
      <a href="/login/credentials" className="btn btn-outline-secondary w-100">
        Sign in with Email
      </a>
    </div>
  );
}

Credentials Provider with Custom Validation

// app/login/credentials/page.tsx — Client component for email/password form
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';

export default function CredentialsLoginPage() {
  const router = useRouter();
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);
    setError('');
    const form = new FormData(e.currentTarget);

    const result = await signIn('credentials', {
      email: form.get('email'),
      password: form.get('password'),
      redirect: false, // handle redirect manually to show errors
    });

    setLoading(false);

    if (result?.error) {
      setError('Invalid email or password. Please try again.');
      return;
    }
    router.push('/dashboard');
    router.refresh(); // revalidate server components with new session
  };

  return (
    <form onSubmit={handleSubmit} className="max-w-sm mx-auto mt-16">
      {error && <div role="alert" className="alert alert-danger">{error}</div>}
      <div className="mb-3">
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required className="form-control" />
      </div>
      <div className="mb-3">
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" required className="form-control" />
      </div>
      <button type="submit" disabled={loading} className="btn btn-primary w-100">
        {loading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
}

JWT vs Database Sessions

Auth.js supports two session strategies. The choice affects latency, scalability, and how quickly session revocation takes effect.

AspectJWT (default)Database sessions
StorageEncrypted cookie (client-side)Row in sessions table
DB query per requestNoYes
Immediate revocationNo — wait for token expiryYes — delete the row
Session data sizeLimited by cookie size (~4 KB)Unlimited (stored server-side)
Requires adapterNoYes (Prisma, Drizzle, etc.)
Best forMost apps, edge deploymentsApps needing instant revocation
// Extending the session type — add role and id to TypeScript types
// types/next-auth.d.ts
import 'next-auth';
import 'next-auth/jwt';

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

  interface User {
    role?: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    role?: string;
    id?: string;
  }
}
Pro Tip: Always extend the TypeScript types in next-auth.d.ts. Without it, accessing session.user.role produces a TypeScript error even though it exists at runtime. The declaration merging approach means you get full autocomplete on your extended session fields.

Protecting Routes with middleware.ts

Next.js middleware runs before the request reaches any route handler or page. It's the correct place to enforce authentication — before any rendering happens, before any data is fetched.

// middleware.ts — project root, same level as auth.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export default auth((req) => {
  const { nextUrl, auth: session } = req;
  const isAuthenticated = !!session;

  // Public paths that never need protection
  const publicPaths = ['/', '/login', '/register', '/about', '/pricing'];
  const isPublicPath = publicPaths.some(
    (path) => nextUrl.pathname === path || nextUrl.pathname.startsWith('/api/auth')
  );

  if (isPublicPath) return NextResponse.next();

  // Not authenticated — redirect to login
  if (!isAuthenticated) {
    const loginUrl = new URL('/login', nextUrl.origin);
    loginUrl.searchParams.set('callbackUrl', nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  // RBAC — admin-only routes
  if (nextUrl.pathname.startsWith('/admin') && session.user?.role !== 'admin') {
    return NextResponse.redirect(new URL('/dashboard', nextUrl.origin));
  }

  return NextResponse.next();
});

// Only run middleware on these paths — exclude static assets and _next internals
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',
  ],
};

useSession in Client Components

// app/layout.tsx — wrap with SessionProvider so useSession works client-side
import { SessionProvider } from 'next-auth/react';
import { auth } from '@/auth';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth(); // fetch session server-side for initial state
  return (
    <html lang="en">
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}

// components/UserMenu.tsx — client component using useSession
'use client';
import { useSession, signOut } from 'next-auth/react';

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

  if (status === 'loading') return <div className="spinner-border spinner-border-sm" />;
  if (status === 'unauthenticated') return <a href="/login" className="btn btn-primary btn-sm">Sign in</a>;

  return (
    <div className="dropdown">
      <button className="btn btn-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
        {session?.user?.image && <img src={session.user.image} alt="" width={24} height={24} className="rounded-circle me-2" />}
        {session?.user?.name}
      </button>
      <ul className="dropdown-menu">
        <li><a className="dropdown-item" href="/dashboard">Dashboard</a></li>
        <li><a className="dropdown-item" href="/settings">Settings</a></li>
        <li><hr className="dropdown-divider" /></li>
        <li>
          <button className="dropdown-item" onClick={() => signOut({ callbackUrl: '/' })}>
            Sign out
          </button>
        </li>
      </ul>
    </div>
  );
}

auth() in Server Components and API Routes

// app/dashboard/page.tsx — Server Component
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

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

  if (!session) redirect('/login'); // belt-and-suspenders check alongside middleware

  return (
    <main>
      <h1>Welcome back, {session.user.name}</h1>
      <p>Role: {session.user.role}</p>
    </main>
  );
}

// app/api/profile/route.ts — API Route
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export async function GET() {
  const session = await auth();
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const user = await prisma.user.findUnique({ where: { id: session.user.id } });
  return NextResponse.json(user);
}

export async function PATCH(req: Request) {
  const session = await auth();
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const body = await req.json();
  const updated = await prisma.user.update({ where: { id: session.user.id }, data: body });
  return NextResponse.json(updated);
}

RBAC with Session Roles

// lib/auth-utils.ts — reusable authorization helpers
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

type Role = 'user' | 'editor' | 'admin';

export async function requireAuth() {
  const session = await auth();
  if (!session) redirect('/login');
  return session;
}

export async function requireRole(role: Role) {
  const session = await requireAuth();
  const hierarchy: Role[] = ['user', 'editor', 'admin'];
  const userLevel = hierarchy.indexOf(session.user.role as Role);
  const requiredLevel = hierarchy.indexOf(role);

  if (userLevel < requiredLevel) {
    redirect('/unauthorized');
  }
  return session;
}

// app/admin/users/page.tsx — admin-only server component
import { requireRole } from '@/lib/auth-utils';

export default async function AdminUsersPage() {
  const session = await requireRole('admin'); // redirects non-admins

  const users = await prisma.user.findMany({ select: { id: true, name: true, email: true, role: true } });

  return (
    <div>
      <h1>User Management</h1>
      <table className="table">
        <thead><tr><th>Name</th><th>Email</th><th>Role</th></tr></thead>
        <tbody>
          {users.map((u) => (
            <tr key={u.id}><td>{u.name}</td><td>{u.email}</td><td>{u.role}</td></tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
// auth.ts — add Resend (or Nodemailer) email provider
import Resend from 'next-auth/providers/resend';

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma), // required for magic links — stores tokens in DB
  providers: [
    // ... other providers
    Resend({
      apiKey: process.env.AUTH_RESEND_KEY,
      from: 'noreply@yourdomain.com',
      // Optional: customize the email template
      sendVerificationRequest: async ({ identifier: email, url, provider }) => {
        const { host } = new URL(url);
        const res = await fetch('https://api.resend.com/emails', {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${provider.apiKey}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            from: provider.from,
            to: email,
            subject: `Sign in to ${host}`,
            html: `<p>Click <a href="${url}">here</a> to sign in. Link expires in 24 hours.</p>`,
          }),
        });
        if (!res.ok) throw new Error('Failed to send verification email');
      },
    }),
  ],
  session: { strategy: 'database' }, // magic links require database sessions
});

// Trigger magic link sign-in from a server action
async function requestMagicLink(formData: FormData) {
  'use server';
  const email = formData.get('email') as string;
  await signIn('resend', { email, redirectTo: '/dashboard' });
}

FAQ

What changed between NextAuth v4 and Auth.js v5?

The major changes in v5: configuration moves to a root-level auth.ts instead of the API route file; getServerSession(authOptions) is replaced by simply calling auth() anywhere; the middleware integration is simplified via export default auth(...); all providers are now imported as named defaults (import GitHub from 'next-auth/providers/github'); and the package name is next-auth@beta while it finalizes the v5 release cycle.

Should I use JWT or database sessions for a SaaS app?

For most SaaS apps, JWT sessions are the right default. They require no database query on every request, work on edge runtimes, and simplify scaling. Switch to database sessions only if you need to: (a) immediately invalidate all sessions when a user is banned or changes their password, (b) store large amounts of session data, or (c) view/manage active sessions in an admin panel. Combine JWT with a short expiry (15 minutes) and a refresh token strategy if you need near-immediate revocation without full database sessions.

How do I protect a specific API route without middleware?

Call await auth() at the top of your route handler and return a 401 if the result is null. This is appropriate for routes that aren't covered by the middleware matcher — for example, webhook endpoints that need different auth logic. Always have middleware as the primary gatekeeper for page routes and use the programmatic check as a belt-and-suspenders measure in sensitive API routes.

Can I use Auth.js with the Next.js Pages Router?

Yes. Auth.js v5 supports both routers. For Pages Router, use getServerSession(req, res, authOptions) in getServerSideProps, and place the handler in pages/api/auth/[...nextauth].ts. The v5 App Router API (calling auth() with no arguments) is App Router-only.

How do I handle auth errors on the custom error page?

Auth.js redirects to your error page with an error query parameter. Common values: OAuthSignin (OAuth flow failed), OAuthCallback (callback URL mismatch), OAuthAccountNotLinked (email already used with different provider), CredentialsSignin (invalid credentials), SessionRequired. Read searchParams.error in your error page and display a user-friendly message for each code.