Next.js Auth With NextAuth.js v5: Complete 2026 Setup Guide

NextAuth.js v5, now officially called Auth.js, ships with a rewritten API that breaks compatibility with v4 in several meaningful ways. The getServerSession function is gone. The configuration file moves from pages/api/auth/[...nextauth].ts to a root-level auth.ts file. Middleware integration is cleaner. And the new auth() function works uniformly across Server Components, Route Handlers, middleware, and Server Actions. If you're starting a new Next.js 15 project or upgrading an existing one, this guide walks through every part of the setup in the order you'll actually need it.

What Actually Broke Between v4 and v5

Understanding the breaking changes first saves you from debugging symptoms of a partially migrated setup. The most important changes:

Configuration file location: In v4, auth lived in pages/api/auth/[...nextauth].ts. In v5, you create a root-level auth.ts file and export the handlers, auth function, and sign in/out helpers from it. There is no more catch-all API route to maintain.

The auth() function replaces getServerSession: Every place you previously called getServerSession(authOptions) in a Server Component or API route, you now import and call auth() directly — no arguments needed. The function resolves the session from request context automatically.

Adapters moved to separate packages: The Prisma adapter is now @auth/prisma-adapter not @next-auth/prisma-adapter. Same pattern for other adapters. Check your import paths when upgrading.

Environment variable names changed: NEXTAUTH_SECRET still works for backward compatibility, but the canonical name is now AUTH_SECRET. Similarly, NEXTAUTH_URL is AUTH_URL in v5 config.

Initial Installation and Configuration

Install the package and the Prisma adapter if you're using database sessions:

npm install next-auth@beta @auth/prisma-adapter
npm install prisma @prisma/client --save-dev

Create your central auth configuration file at the project root:

// auth.ts (root of your project, not inside app/ or pages/)
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" }, // or "jwt" for stateless sessions
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
    }),
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
    Credentials({
      name: "Email & Password",
      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 valid = await bcrypt.compare(
          credentials.password as string,
          user.password
        );
        return valid ? user : null;
      },
    }),
  ],
  callbacks: {
    session({ session, user }) {
      // Attach user ID and role to session object
      session.user.id = user.id;
      session.user.role = user.role;
      return session;
    },
  },
});

Then create the Route Handler that NextAuth uses to handle OAuth callbacks and sign in/out endpoints:

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

Database Sessions With Prisma and PostgreSQL

When you choose strategy: "database", NextAuth stores session tokens in your database and validates them on every request. The Prisma adapter requires specific models in your schema. Here's the required schema addition:

// prisma/schema.prisma
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  password      String?   // only needed for Credentials provider
  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])
}

Run the migration: npx prisma migrate dev --name add-auth-tables. The adapter handles all database reads and writes automatically — you don't write any query logic for sessions.

JWT vs Database Sessions: Choosing Correctly

JWT sessions (the default when no adapter is configured) encrypt session data into a cookie. The server never touches a database per request — it decrypts the cookie and trusts its contents. This makes JWT sessions significantly faster per-request and simpler to deploy (no database dependency for auth), but with a real trade-off: you cannot invalidate a specific session without waiting for the JWT to expire. If a user's account is compromised or they change their password, their existing JWT sessions remain valid until expiry.

Database sessions add one database query per authenticated request to load session data and confirm the token hasn't been revoked. For most SaaS products with login-required dashboards, this query cost is negligible compared to the data queries already happening. The benefit is immediate revocability — delete the session row and the user is logged out instantly on their next request. For B2B products serving organisations in Kerala and India's enterprise sector, where admins may need to force-logout compromised accounts, database sessions are the right default.

Route Protection With middleware.ts

NextAuth v5's middleware integration is cleaner than v4. Create a middleware.ts at your project root:

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

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

  // Define which paths require authentication
  const isProtected = nextUrl.pathname.startsWith("/dashboard") ||
    nextUrl.pathname.startsWith("/settings") ||
    nextUrl.pathname.startsWith("/api/protected");

  const isAuthPage = nextUrl.pathname.startsWith("/login") ||
    nextUrl.pathname.startsWith("/register");

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

  if (isAuthPage && isLoggedIn) {
    return NextResponse.redirect(new URL("/dashboard", nextUrl));
  }

  return NextResponse.next();
});

export const config = {
  matcher: [
    // Skip Next.js internals and all static files
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};

The matcher pattern is critical. Without it, middleware runs on every request including static assets and Next.js internals, degrading performance significantly. The pattern above skips all static file extensions and only runs on page routes and API routes.

Accessing Session in Server vs Client Components

Server Components and Client Components access the session differently, and mixing these up is the most common source of confusion when first working with NextAuth v5.

In a Server Component, call auth() directly:

// app/dashboard/page.tsx (Server Component — no "use client")
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>
  );
}

In a Client Component, use the useSession hook — but you need to wrap your app in SessionProvider first:

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

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

// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

// Any Client Component
"use client";
import { useSession } from "next-auth/react";

export function UserMenu() {
  const { data: session, status } = useSession();
  if (status === "loading") return <span>Loading...</span>;
  if (!session) return <a href="/login">Sign in</a>;
  return <span>{session.user.name}</span>;
}

Role-Based Access Control

Extending the session to carry role data requires two additions: storing the role in your user model (already shown in the Prisma schema above) and exposing it through the session callback.

First, extend the TypeScript types so you don't get type errors accessing session.user.role:

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

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

  interface User {
    role: string;
  }
}

Then protect admin routes in middleware by checking the role from the session:

// In middleware.ts, add role check
if (nextUrl.pathname.startsWith("/admin")) {
  if (!isLoggedIn) {
    return NextResponse.redirect(new URL("/login", nextUrl));
  }
  if (req.auth?.user?.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", nextUrl));
  }
}

Many B2B applications built for Indian enterprises avoid OAuth providers entirely — employees don't want to link personal Google accounts, and organisations may not use Google Workspace. Magic link email authentication solves this cleanly.

// In auth.ts — add the Email provider
import Resend from "next-auth/providers/resend";

// Add to providers array:
Resend({
  apiKey: process.env.AUTH_RESEND_KEY,
  from: "noreply@yourcompany.com",
  // Customise the email template
  sendVerificationRequest: async ({ identifier: email, url, provider }) => {
    const { host } = new URL(url);
    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>`,
      }),
    });
  },
}),

The Email provider requires a database adapter — it stores verification tokens in the VerificationToken table. When the user clicks the link, NextAuth validates the token, creates a session, and redirects to the callback URL. No password storage or OAuth app registration needed.

Common Pitfalls and Production Gotchas

Missing AUTH_SECRET in production: NextAuth v5 requires AUTH_SECRET in production and will throw an error on startup without it. Generate one with npx auth secret or openssl rand -base64 32. Never hardcode it — always load from environment variables.

Callback URL misconfiguration on Vercel: OAuth providers require exact redirect URIs. Add https://yourapp.com/api/auth/callback/google to Google's authorised redirect URIs. For Vercel preview deployments with changing URLs, set AUTH_TRUST_HOST=true so NextAuth trusts the forwarded host header — but never enable this in production without understanding that it trusts incoming headers completely.

Expired sessions not handled gracefully: When a database session expires, auth() returns null. If your Server Components don't check for null and redirect accordingly, users hit an unhandled state. Add explicit null checks in every protected Server Component, or rely on middleware to catch this before the page renders.

Credentials provider and CSRF: The Credentials provider only works with the built-in sign-in form or direct calls to signIn("credentials", {...}) from a client-side action. Calling it from a Server Action doesn't work — use a Server Action only to validate input, then use the client-side signIn function to trigger the actual auth. This is a deliberate CSRF protection design in NextAuth.

For production Next.js applications handling authentication for real users, the setup described here covers the patterns that appear in 95% of projects. The remaining edge cases — custom session stores, multi-tenant auth, SAML/SSO — build on top of this foundation. If you're building a product for Indian businesses and need help evaluating which auth strategy fits your use case, the architecture decisions made at this stage have long-term consequences worth getting right.

Frequently Asked Questions

What is the difference between the auth() function in NextAuth.js v5 and getServerSession in v4?

In NextAuth.js v4, you had to call getServerSession(authOptions) and pass your authOptions configuration explicitly every time you needed the session in a Server Component or API route. In v5, the auth() function is exported directly from your auth.ts config file and works without any arguments — it reads the session from request context automatically. This means less boilerplate per usage, and the configuration stays centralised in one place. The auth() function works in Server Components, Route Handlers, middleware, and Server Actions, making session access consistent across the entire App Router.

When should I use JWT sessions versus database sessions in NextAuth.js v5?

JWT sessions (the default) store session data in an encrypted cookie. They're stateless — no database query needed per request — which makes them faster and simpler to deploy. Use JWT when your application doesn't need to invalidate individual sessions, when users don't need to see all their active sessions, and when you're comfortable with sessions lasting until cookie expiry. Database sessions store only a session token in the cookie; every request queries the database to validate and retrieve session data. Choose database sessions when you need immediate session revocation (for example, after a password change or account suspension), when you need to list a user's active sessions, or when you're building B2B apps where admins may need to forcibly log out users.

How do I configure NextAuth.js v5 for Vercel deployment with the correct callback URLs?

Set AUTH_URL to your production domain in Vercel's environment variables — for example, https://yourapp.com. Also set AUTH_SECRET to a long random string (generate with openssl rand -base64 32). For OAuth providers like Google, add your Vercel production domain to the authorised redirect URIs in Google Cloud Console: https://yourapp.com/api/auth/callback/google. For preview deployments, Vercel generates unique URLs per PR — use AUTH_TRUST_HOST=true in v5 to tell NextAuth to trust the host header. This is the recommended approach for Vercel preview deployments where the URL changes per branch.