Senior expert in Auth.js v5 (NextAuth), Edge-First authentication and security. Optimized for Next.js 16.1.1 and React 19.2.
This skill provides a deep-dive into Auth.js v5, the successor to NextAuth.js. It is specifically optimized for the Next.js 16.1.1 App Router, React 19.2 Server Actions, and Edge Runtime constraints.
In 2026, authentication is no longer just about a login form; it's about a unified session architecture that works across Server Components, Client Components, Middleware, and Server Actions without performance trade-offs.
auth() ProtocolTo avoid "Module not found" errors in the Edge Runtime (Middleware), Auth.js v5 requires a split configuration.
auth.config.ts)This file contains only logic that can run on the Edge. Never import database adapters or heavy Node.js libraries here.
import type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";
// This is the base config for Middleware and Edge functions
export const authConfig = {
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login
}
return true;
},
},
} satisfies NextAuthConfig;
auth.ts)This file imports authConfig and adds the database adapter (Node.js runtime).
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/db"; // Your Prisma client
import { authConfig } from "./auth.config";
export const {
handlers,
auth,
signIn,
signOut,
update // Used to update the session manually
} = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" }, // JWT is mandatory for Edge compatibility
...authConfig,
});
bun add next-auth@beta @auth/prisma-adapter
# Generate with: npx auth secret
AUTH_SECRET=your_ultra_secure_secret_here
# Provider Credentials
AUTH_GITHUB_ID=...
AUTH_GITHUB_SECRET=...
app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
auth() ProtocolOne of the most powerful features of v5 is the universal auth() function. It works everywhere.
import { auth } from "@/auth";
export default async function ProfilePage() {
const session = await auth();
if (!session) {
return <div>Not logged in.</div>;
}
return (
<div>
<h1>Welcome, {session.user?.name}</h1>
<p>Role: {session.user?.role}</p>
</div>
);
}
"use server"
import { auth } from "@/auth";
export async function createPost(content: string) {
const session = await auth();
if (!session) throw new Error("Unauthorized");
// Securely access user data
const authorId = session.user.id;
// ... database logic
}
Use the useSession hook from next-auth/react. Note: You must wrap your app in <SessionProvider>.
"use client"
import { useSession } from "next-auth/react";
export function UserButton() {
const { data: session, status } = useSession();
if (status === "loading") return <div>...</div>;
if (!session) return <button>Login</button>;
return <img src={session.user.image} alt="Avatar" />;
}
The middleware.ts file is the first line of defense.
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
export default NextAuth(authConfig).auth;
export const config = {
// Matcher allows excluding static files and api routes if needed
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
React 19 introduces improved handling for forms and transitions. Use them for your login flows.
"use server"
import { signIn } from "@/auth";
import { AuthError } from "next-auth";
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
try {
await signIn("credentials", formData);
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return "Invalid credentials.";
default:
return "Something went wrong.";
}
}
throw error;
}
}
| Pitfall | Why it's bad | Solution |
| :--- | :--- | :--- |
| Using getServerSession | v4 legacy, slower in v5. | Use auth(). |
| NEXTAUTH_URL | Deprecated in v5. | Use AUTH_URL or let it auto-detect. |
| Prisma in Middleware | Middleware runs on Edge; Prisma needs Node. | Use split config pattern. |
| Client-side only checks | Easily bypassed by disabling JS. | Always verify session on server. |
| Storing Secrets in Client | Security breach. | Keep all secrets in .env.local. |
| Ignoring CSRF | Vulnerable to cross-site attacks. | Use built-in handlers and Actions. |
AUTH_SECRET is rotated periodically.trustHost.!!session, check session.user.role === 'admin'.app/api/auth endpoints.Extending the session object is a common requirement.
next-auth.d.tsimport { type DefaultSession } from "next-auth";
export type ExtendedUser = DefaultSession["user"] & {
role: "ADMIN" | "USER";
id: string;
};
declare module "next-auth" {
interface Session {
user: ExtendedUser;
}
}
callbacks: {
async session({ token, session }) {
if (token.sub && session.user) {
session.user.id = token.sub;
}
if (token.role && session.user) {
session.user.role = token.role as "ADMIN" | "USER";
}
return session;
},
async jwt({ token }) {
if (!token.sub) return token;
const existingUser = await getUserById(token.sub);
if (!existingUser) return token;
token.role = existingUser.role;
return token;
}
}
For more complex scenarios, consult the following specialized guides:
Check if your AUTH_SECRET matches across all environments. If you changed it, all current sessions will be invalidated.
This usually happens when your middleware.ts logic redirects to /login but doesn't exclude /login from the middleware matcher or the authorized check.
When using Prisma or Drizzle, you might need to customize the table names or add extra fields.
// schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
role UserRole @default(USER)
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum UserRole {
ADMIN
USER
}
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])
}
import {
timestamp,
pgTable,
text,
primaryKey,
integer,
} from "drizzle-orm/pg-core"
import type { AdapterAccount } from '@auth/core/adapters'
export const users = pgTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name"),
email: text("email").notNull(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
})
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] }),
})
)
Handling redirects and error messages in multiple languages.
If you are using next-intl or a similar library, your auth logic should respect the current locale.
// auth.config.ts
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const locale = nextUrl.pathname.split('/')[1] || 'en';
const isLoggedIn = !!auth?.user;
if (nextUrl.pathname.startsWith(`/${locale}/dashboard`) && !isLoggedIn) {
return Response.redirect(new URL(`/${locale}/login`, nextUrl));
}
return true;
}
}
While Auth.js doesn't have a "one-click" MFA, you can implement it using the callbacks and a custom session state.
mfa_pending: true./verify-mfa page.mfa_verified: true.// Example JWT Callback for MFA
async jwt({ token, user }) {
if (user) {
token.mfa_required = user.hasMfa;
token.mfa_verified = false;
}
return token;
}
// In Middleware
if (token?.mfa_required && !token?.mfa_verified && pathname !== "/verify-mfa") {
return Response.redirect(new URL("/verify-mfa", nextUrl));
}
Testing auth flows is critical for preventing regressions.
Mock the auth function to test protected components.
import { vi, test, expect } from 'vitest';
import { auth } from '@/auth';
vi.mock('@/auth', () => ({
auth: vi.fn(),
}));
test('shows secret content to logged-in users', async () => {
(auth as any).mockResolvedValue({ user: { name: 'Test User' } });
// Render component and assert
});
Use a dedicated test user and the storageState feature to stay logged in across tests.
import { test, expect } from '@playwright/test';
test('can access dashboard after login', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
auth.config.tsimport type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
export const authConfig = {
providers: [
GitHub,
Credentials({
async authorize(credentials) {
// Validation logic here
return null;
}
})
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
// Logic here
return true;
}
}
} satisfies NextAuthConfig;
auth.tsimport NextAuth from "next-auth";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { authConfig } from "./auth.config";
export const { auth, handlers, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: "jwt" },
...authConfig,
});
middleware.tsimport NextAuth from "next-auth";
import { authConfig } from "./auth.config";
export default NextAuth(authConfig).auth;
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Updated: January 22, 2026 - 15:18
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer