Oliver White
17 May 2026 · 9 min read
The auth requirements for AI Art Arena were straightforward on paper: GitHub OAuth for developers who want to sign in quickly, email/password for users who prefer not to use OAuth, and magic links for users who want passwordless but do not have a GitHub account. In practice, combining all three in a single NextAuth v5 setup with a shared users table and consistent session shape took considerably more refinement than any single tutorial covers.
This is part of the Directed Output build log. The methodology — how the refinement loop turns undocumented problems into solutions you actually own — is at /process.
Each provider has a different relationship with user creation. GitHub OAuth creates a user row on first sign-in if one does not exist. Credentials authentication requires the user to already exist — it is verifying an existing identity, not creating a new one. Magic links need to create the user if they do not exist, then send a tokenised email link. All three flows need to land in the same users table, produce the same session shape, and be treated identically downstream.
| Provider | Creates user? | Requires existing user? | Gotcha |
|---|---|---|---|
| GitHub OAuth | Yes — first sign-in | No | GitHub email can be null if set to private — must handle the null case |
| Credentials | No | Yes | Password must be hashed with bcrypt — never store plaintext, never compare plaintext |
| Magic link | Yes — if not exists | No | Resend client must be instantiated inside the handler, never at module level |
In NextAuth v5, the entire configuration moves from app/api/auth/[...nextauth]/route.ts to a top-level auth.ts file. The route file becomes a one-liner that re-exports the handlers. This separation matters because auth.ts can be imported directly by Server Components, middleware, and API routes — no more passing authOptions around.
The v4 middleware used withAuth() from next-auth/middleware. In v5 that export is gone. The new pattern exports a default function that wraps auth() — your middleware logic runs as a callback inside the NextAuth auth handler. This means security headers, redirects, and auth checks all live in one file and share the same request context.
The session callback fires on every request when using the JWT strategy. Keep it fast — a slow session callback adds latency to every authenticated page load. The Supabase query inside it should hit a cached row in practice, but watch this with profiling if latency becomes a problem.
By default NextAuth's Session type only includes name, email, and image. Adding id and role requires TypeScript module augmentation. Without it, every access to session.user.role will produce a type error — and you will be tempted to add 'as any' casts, which defeats the purpose of having types at all.
Built with this methodology
Most NextAuth v5 tutorials cover one provider in isolation. Running all three in the same app — shared session types, a users table that handles all three, middleware that covers every case — is a different problem. Here is what the integration actually looks like.
From the build log