NextAuth to Auth.js Migration Guide: Fixing Every Breaking Change in 2026
Auth.js v5 (the rebrand of NextAuth) replaces `next-auth` with `@auth/core` plus framework adapters, swaps `getServerSession()` for a universal `auth()` helper, moves config into a root `auth.ts`, rewrites the middleware contract, and bumps every database adapter to a new namespace. Expect roughly a half-day of mechanical edits per app, plus testing.
Key Insight
Auth.js v5 (the rebrand of NextAuth) replaces `next-auth` with `@auth/core` plus framework adapters, swaps `getServerSession()` for a universal `auth()` helper, moves config into a root `auth.ts`, rewrites the middleware contract, and bumps every database adapter to a new namespace. Expect roughly a half-day of mechanical edits per app, plus testing.
A Migration That Looks Easier Than It Is
NextAuth has been the default authentication library for Next.js since 2020. In late 2024 it rebranded to Auth.js and shipped v5 — a rewrite that fixes most of the rough edges that App Router users complained about, but at the cost of a non-trivial migration. By 2026 most production apps have either migrated or built up enough technical debt that the migration is now urgent.
This guide walks through every breaking change between NextAuth v4 and Auth.js v5, with before/after code, the errors you will hit, and the fixes that actually work. It assumes you are running Next.js 14 or 15 with the App Router. Pages Router migrations are similar but the middleware and Server Component bits do not apply.
A Quick History
NextAuth started as a Pages Router-first library. The original API — [...nextauth].js route, getServerSession(), useSession() — was designed for getInitialProps and getServerSideProps. When the App Router landed in Next.js 13, NextAuth bolted on App Router support but the seams showed: getServerSession() worked but felt foreign, middleware needed the awkward withAuth wrapper, and Edge runtime support was a long-running open issue.
Auth.js v5 (the v5 release of next-auth, branded as Auth.js) is the App Router-first rewrite. The same maintainers, mostly the same providers, but a config API designed for Server Components, Server Actions, Route Handlers, and Edge middleware as first-class citizens. The Auth.js team also published bindings for SvelteKit, SolidStart, Express, and Qwik — the rebrand reflects that the core is now framework-agnostic.
If you want the broader Next.js context before diving in, our Next.js authentication tutorial covers the App Router patterns that v5 was designed around.
Why Migrate
Three concrete reasons:
- The `auth()` helper is one function for every context. In v4 you used
getServerSession()in API routes,getServerSession()(different overload) in Server Components,useSession()on the client, and a separategetToken()in middleware. In v5,auth()works in all four places. - Edge runtime support actually works. v4 required workarounds (splitting config, avoiding adapter imports). v5 made the split first-class.
- Modular providers and adapters. v5 published every provider and adapter as a separate package, which keeps your bundle smaller and lets the Auth.js team ship provider fixes without bumping the core.
The downside is the migration cost. Plan for half a day of mechanical edits per app, plus a day of testing. Plan for two days if you have custom database adapter logic or non-trivial JWT/session callbacks.
Step 1: Install the New Packages
The package on npm is still called next-auth, but the version is v5+. The adapter packages all moved namespace from @next-auth/* to @auth/*.
# Remove old packages
npm uninstall next-auth @next-auth/prisma-adapter
# Install v5 + new adapter namespace
npm install next-auth@beta @auth/prisma-adapterAt the time of writing the v5 release is still tagged @beta for some adapter combos. The Auth.js install docs show the current stable tag. Match what they recommend.
Step 2: Rewrite the Config Into a Root auth.ts
In v4 your config lived in app/api/auth/[...nextauth]/route.ts. In v5 it moves to a root auth.ts (or src/auth.ts) and the API route becomes a thin re-export.
v4 — single file:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
const handler = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, token }) {
if (session.user) session.user.id = token.sub!;
return session;
},
},
});
export { handler as GET, handler as POST };v5 — split into two files:
// auth.config.ts (Edge-safe, used by middleware)
import type { NextAuthConfig } from 'next-auth';
import Google from 'next-auth/providers/google';
export default {
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) return isLoggedIn;
return true;
},
},
} satisfies NextAuthConfig;// auth.ts (Node, full config with adapter)
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import authConfig from './auth.config';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
callbacks: {
async session({ session, token }) {
if (session.user) session.user.id = token.sub!;
return session;
},
},
...authConfig,
});// app/api/auth/[...nextauth]/route.ts (thin re-export)
export { GET, POST } from '@/auth';The split lets middleware import auth.config.ts (which has no Node-only dependencies and runs on the Edge) while Server Components and Route Handlers import the full auth.ts with the database adapter.
Step 3: Replace getServerSession With auth()
Every server-side session read changes. getServerSession() is gone. The new pattern is to import auth from your root config and call it.
v4:
// In a Server Component
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) redirect('/signin');
return <Dashboard user={session.user} />;
}v5:
import { auth } from '@/auth';
export default async function Page() {
const session = await auth();
if (!session) redirect('/signin');
return <Dashboard user={session.user} />;
}The same auth() call works in Route Handlers, Server Actions, and middleware. This is the single biggest ergonomic improvement in v5.
Step 4: Migrate the Middleware
withAuth is gone. Middleware now wraps with the auth export.
v4:
// middleware.ts
export { default } from 'next-auth/middleware';
export const config = {
matcher: ['/dashboard/:path*'],
};v5:
// middleware.ts
import NextAuth from 'next-auth';
import authConfig from './auth.config';
export const { auth: middleware } = NextAuth(authConfig);
export const config = {
matcher: ['/dashboard/:path*'],
};If you need custom logic in middleware (rate limiting, geo blocking), wrap the auth helper:
import NextAuth from 'next-auth';
import authConfig from './auth.config';
const { auth } = NextAuth(authConfig);
export default auth((req) => {
if (!req.auth && req.nextUrl.pathname !== '/signin') {
const newUrl = new URL('/signin', req.nextUrl.origin);
return Response.redirect(newUrl);
}
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};The redirect-loop trap mentioned in the FAQs lives here. If your matcher catches /signin and your middleware always redirects unauthenticated requests to /signin, you loop. Always exclude the auth pages from the matcher or check the path explicitly.
Step 5: Client Hooks Mostly Stay
The useSession() hook still works but the import path matters.
v4 and v5 — same code:
'use client';
import { useSession } from 'next-auth/react';
export function UserAvatar() {
const { data: session, status } = useSession();
if (status === 'loading') return <Skeleton />;
if (!session?.user) return null;
return <Avatar src={session.user.image!} />;
}SessionProvider is also unchanged in import location. The breaking change for client code is that you no longer need to wrap your entire app in SessionProvider — Server Components can read the session directly with auth(). Many apps remove the provider entirely after migration and only add it back where they need reactive client-side updates.
Step 6: Database Adapter Swaps
Every adapter package moved. Here is the lookup table:
@next-auth/prisma-adapter→@auth/prisma-adapter@next-auth/drizzle-adapter→@auth/drizzle-adapter@next-auth/mongodb-adapter→@auth/mongodb-adapter@next-auth/supabase-adapter→@auth/supabase-adapter
The Drizzle adapter has the largest schema delta. v5 added support for credentials accounts and tightened the foreign key constraints. The Auth.js docs publish a reference Drizzle schema — diff yours against it before migrating. For broader deployment guidance see our Vercel and Supabase deployment gotchas guide.
For Prisma, the schema additions are minor:
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?
refresh_token_expires_in Int? // <- new in some providers
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}Run prisma migrate dev --name authjs-v5 after editing.
Step 7: Provider Config Breaking Changes
Most providers are stable, but defaults have tightened. The two most common gotchas:
Google: Default scopes used to include profile email openid. v5 requires you to declare them:
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorization: {
params: {
scope: 'openid email profile',
prompt: 'consent',
access_type: 'offline',
response_type: 'code',
},
},
}),GitHub: user:email is no longer included by default. If your callbacks read profile.email, declare it:
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
authorization: { params: { scope: 'read:user user:email' } },
}),For broader TypeScript hygiene around provider configs, see our TypeScript best practices guide.
Step 8: Environment Variables
NEXTAUTH_SECRET is now AUTH_SECRET. NEXTAUTH_URL is now AUTH_URL (and is auto-detected on Vercel — you can usually drop it). The old names still work but emit deprecation warnings.
# .env.local
AUTH_SECRET=<openssl rand -base64 32>
AUTH_GOOGLE_ID=<from Google Cloud Console>
AUTH_GOOGLE_SECRET=<from Google Cloud Console>The provider environment variable convention also changed. v5 will auto-detect AUTH_<PROVIDER>_ID and AUTH_<PROVIDER>_SECRET if you do not pass them explicitly. This shrinks your config:
// With AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET in env, this is enough:
Google,The full list of detected variables is in the Auth.js environment docs.
Step 9: Testing Strategy
You cannot guarantee a clean migration without testing. Here is the order:
- Unit-test the JWT and session callbacks. Mock the inputs, assert the outputs, run before and after migration. The shape of
tokenandsessionshould be identical. - Run an E2E flow with Playwright. Sign in with a test account, navigate to a protected page, sign out. Capture the cookies and compare across versions.
- Test middleware with both authed and unauthed requests. Use
curl -ito verify the redirect status codes andSet-Cookieheaders. - Test the OAuth callback URL specifically. A misconfigured
trustHostsetting (nowtrustHost: trueby default behind a proxy) is the #1 cause of "works locally, fails in prod" issues.
Common Errors and Fixes
`MissingSecret: Please define a 'secret' in production` — Set AUTH_SECRET in your environment. The NEXTAUTH_SECRET fallback works but emits warnings.
`UntrustedHost` — Behind a load balancer, set AUTH_TRUST_HOST=true in env, or pass trustHost: true in your config.
`TypeError: Cannot read properties of undefined (reading 'user')` — You called auth() without awaiting it, or you imported auth from the wrong file (typically the config file instead of the helper file).
`OAuthCallbackError: invalid_grant` — The OAuth flow completed but the token exchange failed. Almost always a clock-skew issue or a stale cached refresh token. Sign out, clear the relevant rows in the Account table, and retry.
Redirect loop on `/signin` — Your matcher includes the signin page. Add an explicit exclusion or check the path inside your wrapped middleware.
Final Checklist
Before flipping production traffic to v5:
- [ ] All
getServerSession()calls replaced withauth() - [ ]
auth.config.tshas no Node-only imports (no Prisma, no fs, no crypto beyond Web Crypto) - [ ] Middleware tested with authed and unauthed requests
- [ ] OAuth providers tested end-to-end (sign in, sign out, refresh token flow)
- [ ] Database adapter package swapped and schema migrated
- [ ]
AUTH_SECRETset in production - [ ] Cookie name verified to be unchanged (or session-aware migration in place)
- [ ] Sentry / observability still capturing auth errors
When all of those are checked, ship it. The migration looks intimidating because it touches every layer, but the actual line count of changes is small and the result is dramatically easier to maintain.
For the wider context on shipping authentication in modern Next.js apps, see our pillar guide: [Next.js Authentication Tutorial 2026](/blog/nextjs-authentication-tutorial-2026).
Key Takeaways
- Auth.js v5 is a rebrand and rewrite of NextAuth — the package, the config shape, and the runtime helpers all changed
- The biggest win is universal `auth()` — one helper that works in Server Components, Route Handlers, Server Actions, middleware, and API routes
- Edge runtime support requires splitting your config: a thin Edge-safe `auth.config.ts` and a full-fat `auth.ts` for Node-only adapters
- Database adapters moved from `@next-auth/*-adapter` to `@auth/*-adapter` — Prisma, Drizzle, MongoDB, and Supabase all need package swaps
- Middleware no longer uses `withAuth` — you wrap your middleware function with the `auth` helper exported from your config
- The `useSession()` client hook still works, but `SessionProvider` is now imported from `next-auth/react` (not `next-auth`)
- Most "works on local but breaks in production" errors trace to `AUTH_SECRET` not being set, or the trusted host check failing behind a proxy
Frequently Asked Questions
Is Auth.js v5 the same as NextAuth v5?
Yes. The project rebranded from NextAuth.js to Auth.js to reflect that it now supports SvelteKit, Express, SolidStart, and Qwik in addition to Next.js. The Next.js binding is still called `next-auth` on npm to preserve compatibility, but the docs, the config shape, and the runtime helpers are all the v5 generation. If you see a tutorial that imports `getServerSession`, it is for v4.
Do I have to migrate? Can I stay on v4?
You can stay on v4, but the maintenance posture is end-of-life. Security patches will land for known CVEs, but new providers, App Router improvements, and Edge runtime fixes are v5 only. If you are on Next.js 14+ and using the App Router, the friction of v4 (no `auth()` helper, awkward Server Component access, Edge incompatibility) compounds quickly. Most production apps should plan a migration window in 2026.
Will my existing user sessions survive the migration?
If you are using the JWT strategy (the default) and you keep the same `AUTH_SECRET`, sessions survive — the cookie format is compatible. If you are using the database strategy, sessions also survive because the row schema is unchanged. The main gotcha is that the cookie name changed from `next-auth.session-token` to `authjs.session-token` in some configurations. Set `cookies.sessionToken.name` explicitly during migration to avoid logging users out.
What breaks with the Prisma adapter?
The package moves from `@next-auth/prisma-adapter` to `@auth/prisma-adapter`. The schema is mostly compatible, but Auth.js v5 expects `Account.refresh_token_expires_in` as `Int?` rather than `BigInt?` for some providers, and the `VerificationToken` model now uses a composite key that older migrations may not have. Run `prisma migrate diff` against the latest [Auth.js Prisma schema](https://authjs.dev/getting-started/adapters/prisma) before applying changes.
How does Edge runtime support work?
Auth.js v5 splits config into two files. `auth.config.ts` contains only Edge-safe settings (providers without DB adapters, callbacks that do not import Node modules) and is imported by middleware. `auth.ts` extends that config with the database adapter and is imported by Route Handlers and Server Components. This split lets the JWT validation run in Edge middleware while DB access runs in Node — the pattern Vercel recommends for production.
My middleware redirects loop after migration. What is wrong?
The most common cause is calling `auth()` inside middleware before configuring the public route matcher. The new `auth` middleware wrapper expects you to either return a response from inside the callback (allow), let it fall through (the wrapper redirects to signin), or short-circuit on public routes. If your matcher includes the signin page itself, you create a loop. Add an explicit check: `if (req.nextUrl.pathname === '/signin') return`.
How do I test the migration without breaking production?
Run both versions side by side using a feature flag and a duplicate route. Deploy the v5 codepath to a preview URL, log in with a test account, and verify the cookie, the `/api/auth/session` payload, and protected routes. The Auth.js team publishes an [official migration guide](https://authjs.dev/getting-started/migrating-to-v5) with a checklist. After testing, flip the flag for 1% of traffic, then 10%, then full rollout.
What about social provider scopes — do those change?
Provider configs themselves are mostly stable, but the `authorization.params.scope` field is now strongly typed and the default scopes have tightened. Google removed `profile email openid` as a default; you now declare exactly what you need. GitHub's default no longer includes `user:email` — if your app reads emails, declare that scope explicitly. Test the OAuth flow end to end after migrating because silent scope changes will break "I logged in but my email is null" cases.
About the Author
Elena Rodriguez
Full-Stack Developer & Web3 Architect
BS Software Engineering, Stanford | Former Lead Engineer at Coinbase
Elena Rodriguez is a full-stack developer and Web3 architect with seven years of experience building decentralized applications. She holds a BS in Software Engineering from Stanford University and has worked at companies ranging from early-stage startups to major tech firms including Coinbase, where she led the frontend engineering team for their NFT marketplace. Elena is a core contributor to several open-source Web3 libraries and has built dApps that collectively serve over 500,000 monthly active users. She specializes in React, Next.js, Solidity, and Rust, and is particularly passionate about creating intuitive user experiences that make Web3 technology accessible to mainstream audiences. Elena also mentors aspiring developers through Women Who Code and teaches a popular Web3 development bootcamp.