Back to notes
July 16, 20267 min read

NextAuth v5 (Auth.js) in production with Google + email/password

How I set up NextAuth v5 in ReclamaAI with Google OAuth and email/password coexisting on the same account. Sessions, callbacks, account linking, and the gotchas that almost made me go back to Clerk.

authstack-decisions

NextAuth v5 (now called Auth.js to be friendlier) is one of those pieces of software where the official docs get you 80% there, and the last 20% you discover the hard way in production. Here's the recipe that works for me in ReclamaAI with Google OAuth + email/password coexisting, and the details I wish I'd known from the start.

The base setup

My auth.ts exports three things: handlers (the POST/GET route handlers for the auth endpoint), signIn/signOut (server actions for use from server components), and auth (helper to read the session in any server component). It's the cleanest API NextAuth has had.

Two providers: Google (with minimal scope: profile and email) and Credentials (email + password with bcrypt). The adapter is the official Prisma one. Session strategy is "jwt", not database — faster for frequent reads and secure enough with rotation.

Gotcha #1: automatic account linking

Imagine this flow: María signs up with email/password (maria@gmail.com) in March. In May she comes back, doesn't remember she registered, and clicks "Sign in with Google" using the same Gmail account. NextAuth v5 by default rejects the login with an OAuthAccountNotLinked error. To the user, that looks like "it won't let me in and I don't understand why".

The solution is NOT to enable allowDangerousEmailAccountLinking: true on the Google provider. That's insecure because anyone registering a Gmail can link to an existing email/password account. The right solution is:

  1. Detect the OAuthAccountNotLinked error on the login page.
  2. Show the user: "you already have an account with this email. Sign in with your password, and from your profile you can link Google".
  3. On the profile page, offer a "link Google" button that requires the user to already be authenticated via password.

More friction, yes. But secure and predictable.

Gotcha #2: session callbacks and extra data

The default session contains just user.id, user.email, user.name. If you want session.user.role or session.user.credits available in every request, you have to put them manually in the session callback.

What I learned the hard way: the session callback runs on every request that calls auth(). If you do heavy queries there, your app gets slow and you don't know why. My rule: the session callback only reads from the JWT (already in memory). Extra data goes into the JWT from the jwt callback, and only when there's a login or update event — not on every read.

Gotcha #3: the session doesn't update on its own

If a user buys credits and I want their session.user.credits to reflect the new total immediately, it WON'T happen magically. The session lives in a signed cookie, and it only regenerates when the user logs in again or when you call update() explicitly from the client.

The solution: after any backend action that changes UI-relevant data (purchase, plan change, role change), the client calls useSession().update() to force regeneration. This triggers a new jwt callback with the trigger: "update" flag, where you can reload data from the database.

Gotcha #4: middleware vs server components

In Next.js 15+, middleware runs in edge runtime (Edge functions). NextAuth v5 can read the session there, but if your jwt callback does anything Node-only (Prisma, bcrypt), middleware breaks on edge.

The official solution: split the config into two files. auth.config.ts has only what's edge-safe (basic providers, callbacks that don't touch DB, custom pages). auth.ts imports the config and adds the Prisma adapter and heavy callbacks, and is used only from server components and route handlers, NOT from middleware. It's ugly but it works.

Why not Clerk

Clerk is genuinely easier. If you're starting a product and don't have cost or control constraints, use it and don't look back. I don't use it for two reasons: cost scales fast as active users grow (the free tier is generous but the second tier rises quickly), and moving users out of Clerk if you later decide to leave is work. With NextAuth + Prisma, my users live in my database, they're mine, and if tomorrow I want to change auth providers I just change the adapter.

What's worth doing from day one

  • Detailed logging in the signIn, jwt, session callbacks. When something breaks in production, you'll thank yourself for being able to see what event triggered what callback.
  • Email verification for credentials. If someone signs up with email/password, send a confirmation email and block login until they verify. If you don't, you'll have ghost accounts and signup spam.
  • Rate limiting on the credentials login endpoint. NextAuth doesn't do it for you.

In total, the initial setup took me 3 hours. The gotchas took another 6 hours over the first weeks. Completely worth it vs implementing auth by hand.