Auth.js v5 (formerly NextAuth) is a complete rewrite. Most tutorials online are still for v4. Here's the definitive guide for v5 with Next.js 16 App Router.
Why v5?
Auth.js v5 brings:
- Native App Router support
- Better TypeScript types
- Edge runtime compatibility
- Simpler configuration
- Built-in CSRF protection
Installation
npm install next-auth@beta @auth/prisma-adapter
The auth configuration
Create a single auth config file that exports everything:
// src/lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: "jwt" },
pages: {
signIn: "/login",
error: "/login",
},
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({
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.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) return null;
// Use bcrypt.compare() in production
const isValid = credentials.password === user.password;
if (!isValid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
callbacks: {
async session({ token, session }) {
if (token.sub && session.user) {
session.user.id = token.sub;
session.user.role = token.role as string;
}
return session;
},
async jwt({ token }) {
if (!token.sub) return token;
const user = await db.user.findUnique({
where: { id: token.sub },
select: { role: true },
});
if (user) token.role = user.role;
return token;
},
},
});
The key difference from v4: everything is exported from a single NextAuth() call. No separate authOptions.
Route handler
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
That's it. Two lines.
Protecting pages (Server Components)
// Any server component or page
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect("/login");
return <h1>Welcome, {session.user.name}</h1>;
}
Protecting API routes
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ... your logic
}
Sign in and sign out
// Server action
import { signIn, signOut } from "@/lib/auth";
// In a form
<form action={async () => {
"use server";
await signIn("google");
}}>
<button>Sign in with Google</button>
</form>
<form action={async () => {
"use server";
await signOut();
}}>
<button>Sign out</button>
</form>
Extending the session type
Add custom fields (like role) to the session:
// src/types/next-auth.d.ts
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
}
The Prisma schema
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String?
role Role @default(USER)
accounts Account[]
sessions Session[]
}
model Account {
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?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
enum Role {
USER
ADMIN
}
Common mistakes
-
Using
getServerSessionfrom v4 — In v5, just useauth() -
Forgetting the route handler — The
[...nextauth]/route.tsfile is required - JWT vs database sessions — Use JWT for serverless/edge, database for traditional servers
-
Not extending types — Add the
.d.tsfile or TypeScript will complain about custom fields
Want this pre-built?
All of the above (plus Stripe billing, AI chat, email, and a full dashboard) comes pre-wired in LaunchKit — a production-ready Next.js SaaS starter kit.
Top comments (0)