Authentication in Next.js App Router with Auth.js (NextAuth v5)
Learn how to implement production-ready authentication in Next.js App Router using Auth.js (NextAuth v5) — covering session management, protected routes via Proxy, OAuth providers, credentials login, and the architectural decisions that keep your app secure and maintainable.
TL;DR — Key Takeaways
- Auth.js (NextAuth v5) is the de facto authentication library for Next.js — it handles sessions, OAuth providers, and credentials login with minimal configuration
- Session data is stored in a signed, encrypted cookie — no database required for basic auth, but a database adapter is recommended for production
- Protect routes at the Proxy level for the fastest possible redirect — before the page even starts rendering
- Always verify the session in Server Components and Server Actions — Proxy is a first line of defense, not the only one
- Auth.js v5 introduced a new unified config that works across Proxy, Server Components, and API routes without duplication
Why Auth.js and Not Something Custom
Authentication is one of those areas where rolling your own solution is almost always the wrong decision. Sessions, CSRF protection, token rotation, OAuth flows, secure cookie handling — each of these has well-documented failure modes that Auth.js handles by default.
Auth.js (the library formerly known as NextAuth) in its v5 release was rebuilt from the ground up for the App Router. The unified configuration model means you define your auth logic once and use it everywhere — in Proxy, Server Components, Server Actions, and API routes — without duplication or inconsistency.
Installation and Configuration
npm install next-auth@beta
npx auth secretThe auth secret command generates a AUTH_SECRET environment variable — the key used to sign and encrypt session cookies. This must be set in production.
// auth.ts — the single source of truth for auth configuration
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { verifyPassword } from "@/lib/crypto";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
// OAuth provider — one-click setup
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
// Credentials provider — email + password
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.users.findUnique({
where: { email: credentials.email as string },
});
if (!user) return null;
const isValid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!isValid) return null;
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
};
},
}),
],
callbacks: {
// Include custom fields in the JWT token
jwt({ token, user }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
// Include custom fields in the session
session({ session, token }) {
session.user.role = token.role as string;
session.user.id = token.id as string;
return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
});// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;This single auth.ts file is imported everywhere auth is needed. No duplication.
Extending the Session Type
By default, session.user only contains name, email, and image. For B2B applications where you need role, companyId, or other custom fields, extend the types:
// types/next-auth.d.ts
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "admin" | "manager" | "viewer";
companyId: string;
} & DefaultSession["user"];
}
}With this in place, session.user.role is fully typed throughout your application.
Protecting Routes in Proxy
The fastest place to redirect unauthenticated users is in proxy.ts — before any page starts rendering. Auth.js v5 integrates directly with Proxy:
// proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
const intlProxy = createIntlMiddleware({
locales: ["en", "sr"],
defaultLocale: "en",
localePrefix: "always",
});
const PROTECTED_ROUTES = ["/dashboard", "/account", "/admin", "/rfq"];
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check auth for protected routes
const isProtected = PROTECTED_ROUTES.some((route) =>
pathname.includes(route)
);
if (isProtected) {
const session = await auth();
if (!session) {
const loginUrl = new URL("/en/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl, { status: 302 });
}
// Role-based access control
if (pathname.includes("/admin") && session.user.role !== "admin") {
return NextResponse.redirect(new URL("/en/dashboard", request.url));
}
}
// i18n routing
const response = intlProxy(request);
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/auth).*)"],
};Note the matcher excludes api/auth — the Auth.js callback routes must be publicly accessible or the OAuth flow breaks.
Accessing the Session in Server Components
For pages that need to personalize content or make auth-dependent data fetches, access the session directly in the Server Component:
// app/[locale]/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export default async function DashboardPage() {
const session = await auth();
// Double-check auth even though Proxy already checked
// Proxy is a first line of defense, not the only one
if (!session) {
redirect("/login");
}
// Use session data to scope the database query
const orders = await db.orders.findMany({
where: { companyId: session.user.companyId },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div className="mx-auto max-w-5xl px-4 py-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-gray-500">
Welcome back, {session.user.name}
</p>
</div>
<span className="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 capitalize">
{session.user.role}
</span>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold">Recent Orders</h2>
<ul className="mt-4 divide-y divide-gray-100 rounded-xl border border-gray-200">
{orders.map((order) => (
<li key={order.id} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">{order.reference}</span>
<span className="text-sm text-gray-500">{order.status}</span>
</li>
))}
</ul>
</div>
</div>
);
}Always verify the session in Server Components even when Proxy protects the route. Defense in depth — if someone bypasses Proxy, the page still won't render sensitive data.
Login and Sign Out UI
// components/LoginForm.tsx
"use client";
import { useActionState } from "react";
import { signIn } from "@/auth";
async function loginAction(
prevState: { error?: string },
formData: FormData
) {
"use server";
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/dashboard",
});
} catch (error) {
return { error: "Invalid email or password." };
}
return {};
}
export function LoginForm() {
const [state, action, isPending] = useActionState(loginAction, {});
return (
<form action={action} className="space-y-5">
{state.error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{state.error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
name="email"
type="email"
required
autoComplete="email"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Password</label>
<input
name="password"
type="password"
required
autoComplete="current-password"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
>
{isPending ? "Signing in..." : "Sign in"}
</button>
</form>
);
}// components/SignOutButton.tsx
"use client";
import { signOut } from "@/auth";
export function SignOutButton() {
return (
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<button
type="submit"
className="text-sm font-medium text-gray-500 hover:text-gray-700"
>
Sign out
</button>
</form>
);
}Database Adapter for Production
The default JWT session strategy stores everything in a cookie. This works for simple auth but has limitations — you can't invalidate sessions server-side, and the cookie size limits what you can store.
For production B2B applications, use a database adapter:
npm install @auth/drizzle-adapter
# or
npm install @auth/prisma-adapter// auth.ts — with database adapter
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { db } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: "database" }, // Store sessions in DB instead of cookie
providers: [
// ... your providers
],
});With a database adapter, sessions are stored in your database. You can revoke them programmatically, see who is logged in, and store arbitrary amounts of user data without cookie size concerns.
Protecting Server Actions
Server Actions that modify sensitive data must also verify the session:
// app/[locale]/dashboard/actions.ts
"use server";
import { auth } from "@/auth";
export async function updateOrderStatus(orderId: string, status: string) {
const session = await auth();
// Always verify auth in Server Actions
if (!session) {
throw new Error("Unauthorized");
}
// Role-based authorization
if (session.user.role !== "admin" && session.user.role !== "manager") {
throw new Error("Insufficient permissions");
}
// Scope the update to the user's company
await db.orders.update({
where: {
id: orderId,
companyId: session.user.companyId, // Prevents cross-company data access
},
data: { status },
});
}The companyId scope is critical in multi-tenant B2B applications. Even if an authenticated user somehow calls an action with another company's orderId, the companyId check ensures they can only modify their own data.
SEO Considerations
Authentication and SEO interact in important ways for B2B applications:
Public vs. private pages: Marketing pages, product listings, and blog content should never require authentication. Only dashboard, account, and transactional pages should be behind auth. Over-protecting pages removes them from Google's index entirely.
Login page indexing: Your /login page should have noindex — it has no SEO value and wastes crawl budget.
// app/[locale]/login/page.tsx
export const metadata = {
robots: { index: false, follow: false },
};Soft redirects for bots: When Googlebot hits a protected route, it receives a redirect to /login. This is expected — protected pages are not indexable by design. Ensure the redirect is consistent (don't serve a 200 with a login form to bots while redirecting real users).
Common Pitfalls
1. Only protecting routes in Proxy
Proxy runs before every request, but it can be bypassed or misconfigured. Always verify the session in Server Components and Server Actions as a second layer. Never assume that if a user reached the page, they must be authenticated.
2. Exposing sensitive data in the session cookie
JWT sessions are encoded (not encrypted by default in some configurations). Don't store sensitive data — passwords, payment info, internal IDs that could enable enumeration — in the session. Store only what's needed for the UI: user ID, name, email, role.
3. Not scoping database queries to the authenticated user
In multi-tenant applications, every database query on a protected page must include a companyId or userId filter. Without it, an authenticated user from Company A could potentially access Company B's data by manipulating request parameters.
4. Forgetting to exclude Auth.js routes from Proxy matcher
The api/auth routes handle OAuth callbacks and must be publicly accessible. Including them in your Proxy auth check will break the OAuth flow — users will be redirected to login mid-OAuth callback, creating an infinite loop.
Frequently Asked Questions (FAQ)
What is the difference between Auth.js v4 (NextAuth) and v5?
Auth.js v5 was rebuilt specifically for React Server Components and the App Router. The major changes: a unified auth() function that works in Proxy, Server Components, and Server Actions without separate imports; a new configuration structure in a root auth.ts file; and improved TypeScript types. If you're starting a new project, use v5. If you're on v4 with Pages Router, migration is significant but well-documented.
Should I use JWT or database sessions?
For simple applications or those without a database, JWT sessions (stored in cookies) are fine. For production B2B applications, database sessions are strongly preferred — you can invalidate them on demand (force logout all devices), they scale to larger session payloads, and you have a complete audit trail of active sessions.
Can I use Auth.js with a custom database (like MySQL with mysql2)?
Yes. Auth.js has official adapters for Prisma, Drizzle, and several others. For mysql2 without an ORM, you'd need to implement a custom adapter — which is possible but involves writing implementations for createUser, getUser, createSession, and other methods. For most projects, using Drizzle with MySQL is the pragmatic choice.
How do I handle auth in API routes that need to be public?
API routes used by webhooks or third-party integrations should use secret-based auth (a shared secret in the Authorization header) rather than session-based auth. Session cookies are for browser-based flows only. For webhook receivers, validate the webhook signature from the provider instead.
Series: Next.js & Modern Web
- 1
- 2
- 3
- 4
- 5
- 6
- 7Authentication in Next.js App Router with Auth.js (NextAuth v5) (You are here)