October 25, 2026
NextAuth v4 to Auth.js v5: The Migration Nobody Warned Me About
I opened an old side project to add a second OAuth provider and none of the imports resolved. next-auth/client — gone. Providers.Google —…

By @rnab
3 min read
I opened an old side project to add a second OAuth provider and none of the imports resolved. next-auth/client — gone. Providers.Google — gone. The [...nextauth].ts route I'd written under pages/ didn't fit the App Router I'd since moved to. The library I remembered as "the easy auth one for Next.js" had turned into Auth.js v5, and the API is different enough that copy-pasting a 2022 tutorial gets you a wall of red squiggles. Here's what actually changed, with code that compiles against next-auth@5.0.0-beta.32 and Next.js 15.
The old shape (v4), for reference
This is roughly what the tutorials still show, and what I had:
// pages/api/auth/[...nextauth].ts — v4
import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';
export default NextAuth({
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
async session(session, user) {
session.userId = user.id;
return session;
},
},
});// pages/api/auth/[...nextauth].ts — v4
import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';
export default NextAuth({
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
async session(session, user) {
session.userId = user.id;
return session;
},
},
});Every line of that has moved. Providers as a default namespace, the two-positional-argument session(session, user) callback, the config living in the route file — all v4. If you're carrying this forward, don't patch it. Replace it.
v5: config moves to a root auth.ts
The center of gravity is now a single auth.ts at the project root that exports functions you import everywhere else.
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [Google],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
if (token.id) session.user.id = token.id as string;
return session;
},
},
});import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [Google],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
if (token.id) session.user.id = token.id as string;
return session;
},
},
});Three things to notice. The provider is a bare Google import, not Providers.Google — and I passed it as [Google] with no options object, because v5 reads AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET from the environment automatically. The callbacks now take a single destructured argument ({ session, token }), not positional ones; getting that wrong is the most common v4-muscle-memory error. And to put a value on the session you route it through the JWT: set it on token in the jwt callback, read it off token in the session callback. There's no user argument in the session callback under the JWT strategy — the token is your carrier.
The route handler shrinks to two lines
The [...nextauth] file still exists, but under app/ now, and it does nothing except re-export what auth.ts already built:
import { handlers } from '@/auth';
export const { GET, POST } = handlers;import { handlers } from '@/auth';
export const { GET, POST } = handlers;That's the whole file: app/api/auth/[...nextauth]/route.ts. In v4 this file held your entire config. In v5 it holds two named exports and defers to the root.
Protecting a page: await auth(), no hook
The v4 way was the useSession hook returning a [session, loading] tuple in a client component. In the App Router you call auth() on the server instead:
import { auth } from '@/auth';
export default async function Dashboard() {
const session = await auth();
if (!session) return You must be signed in to view this page.;
return Welcome to the dashboard, {session.user?.name}.;
}import { auth } from '@/auth';
export default async function Dashboard() {
const session = await auth();
if (!session) return You must be signed in to view this page.;
return Welcome to the dashboard, {session.user?.name}.;
}No hook, no loading state, no next-auth/client import (that module doesn't exist anymore). The component is async, auth() returns the session or null, and there's no client-side flash of "loading" because the check happened on the server before the HTML went out.
If you'd rather gate a whole route segment than check inside each page, auth doubles as middleware:
export { auth as middleware } from '@/auth';
export const config = {
matcher: ['/dashboard/:path*'],
};export { auth as middleware } from '@/auth';
export const config = {
matcher: ['/dashboard/:path*'],
};Same exported function, dropped into middleware.ts, guarding everything under /dashboard.
The TypeScript part that trips everyone
session.user.id will not typecheck out of the box — the default Session type has no id. You have to augment the module, and you have to augment two of them, because the value lives on the JWT before it reaches the session:
import 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
declare module 'next-auth/jwt' {
interface JWT {
id?: string;
}
}import 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
declare module 'next-auth/jwt' {
interface JWT {
id?: string;
}
}Put that in a .d.ts your tsconfig includes. Without the next-auth/jwt half, token.id in the jwt callback is a type error even though the next-auth half fixed the session side. I lost twenty minutes to exactly that — fixing the Session interface, seeing the session-callback error clear, and not understanding why the jwt callback still complained. Two modules, two augmentations.
I verified all of the above against next-auth@5.0.0-beta.32 with Next.js 15.5 and React 19: the auth.ts config, the route handler, the server-component page, the middleware, and the module augmentation all pass tsc --noEmit under strict.
Is it worth migrating?
If you're on v4 and it works, v4 still works — there's no forced march. But the moment you move to the App Router, v5 stops being optional in spirit: the useSession/pages/ shape fights the server-component model, and every v5 pattern (await auth(), config in one root file, environment-inferred providers) is less code than what it replaces. The migration reads scary because the imports all moved at once. It's actually smaller once you're through it — my route handler went from a full config to two lines. Do it when you touch auth for the next real reason, not before.