Back to Blog
Technical SEO 8 min readJul 9, 2026

NextAuth v5 with App Router: The Right Auth Pattern for AI SaaS

Supabase Auth is an easy start but a complex dependency as your application grows. NextAuth v5 with App Router gives you full ownership of the auth layer — credentials, OAuth, JWT sessions, and middleware — without vendor lock-in.

When you start a Next.js SaaS project with Supabase Auth, it feels like the right call: hosted auth, magic links, social providers, row-level security all in one. But as the product matures, the friction compounds: SSR client initialization on every request, cookie management across server and client components, middleware that runs Supabase SDK code in the edge context, and tight coupling between your identity layer and your database vendor. NextAuth v5 (Auth.js beta) gives you a clean alternative: full control, no vendor lock-in, and a pattern that works correctly with the App Router.

The Migration Pattern

The migration has three core steps. First: replace the Supabase Auth client with NextAuth configuration in a central auth.ts file that exports handlers, auth, signIn, and signOut. Second: replace the Supabase middleware with the NextAuth auth() middleware wrapper — export default auth((req) => { ... }) where req.auth holds the session or null. Third: replace all server component auth checks (supabase.auth.getUser()) with const session = await auth() and read session.user.id.

Credentials Provider with bcryptjs

For email/password authentication, the Credentials provider validates credentials in the authorize callback. Hash passwords with bcryptjs at cost factor 12 on signup (hash(password, 12)) and verify with compare on login. The authorize callback returns a user object on success and null on failure — NextAuth handles the session creation and JWT signing. Store the passwordHash column on your users table (added via Prisma schema migration). The signup route is a standard API route handler that validates input with Zod, hashes the password, calls createUserWithPassword, and returns 201 or 409 for duplicate email.

TypeScript type narrowing is lost across await expressions. If you conditionally check user.email and then await a dynamic import, TypeScript re-widens user.email to string | null after the await. Fix: capture const email = user.email before the first await, then use email (type narrowed to string) in all subsequent calls.

JWT Session Strategy

Use the JWT session strategy (session: { strategy: "jwt" }) rather than the database session strategy. The JWT strategy keeps session state in a signed cookie with no database read on each request — critical for App Router server components that read auth on every render. The jwt callback receives the token and can attach the database user ID (token.id = user.id for credentials, or a database lookup for OAuth). The session callback threads the id through to session.user.id where server components can read it.

Middleware Route Protection

The NextAuth v5 middleware pattern is clean: import auth from your auth.ts, export default auth((req) => { /* check req.auth and return redirect or NextResponse.next() */ }), and export a config with a matcher that excludes static assets. Protected prefixes (dashboard, API routes, etc.) are checked against req.nextUrl.pathname. API routes return 401 JSON; page routes redirect to /login. The matcher pattern uses a negative lookahead to exclude _next/static, _next/image, and static file extensions.

Read the full AdNexis migration case study — from Supabase Auth to NextAuth v5 in a production Next.js 15 App Router application.

See the changelog
Tags: NextAuth v5 App Router authentication JWT bcryptjs Supabase migration middleware TypeScript