Authentication Guide

NextAuth.js v5 setup with Credentials + OAuth (Google/GitHub), JWT sessions, role-based admin access, signup/register flow, and Edge-safe middleware for Vercel.

Overview

Authentication is handled by NextAuth.js v5 (Auth.js) with Credentials (email/password) and optional OAuth providers (Google/GitHub). Sessions use JWT strategy (no database sessions). Shared options (pages, session, JWT/session callbacks) live in src/lib/auth.config.ts; the full app config in src/lib/auth.ts spreads that file and adds providers plus Prisma/bcrypt logic. The admin panel at /admin/* is protected by Next.js middleware that validates the JWT session without importing the database layer.

The auth UI includes both /admin/login and /admin/signup. Email sign-up posts to /api/auth/register, then signs in automatically. OAuth sign-in/up buttons are shown when public feature flags are enabled.

NextAuth Configuration

The route handler at src/app/api/auth/[...nextauth]/route.ts imports handlers from src/lib/auth.ts. That file merges shared config with the Credentials provider:

// src/lib/auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import bcrypt from "bcryptjs";
import { authConfig } from "./auth.config";
import { prisma } from "./prisma";

export const { handlers, signIn, signOut, auth } = NextAuth({
  pages: authConfig.pages,
  session: authConfig.session,
  providers: [
    ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
      ? [Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET })]
      : []),
    ...(process.env.GITHUB_ID && process.env.GITHUB_SECRET
      ? [GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET })]
      : []),
    Credentials({
      /* authorize → prisma.user.findUnique + bcrypt.compare */
    }),
  ],
});

Edge-safe defaults (no Prisma/bcrypt) are defined in src/lib/auth.config.ts — see Edge vs Node (Vercel). OAuth providers are conditionally added only when their server-side credentials exist, so local/dev can run without social login.

Edge vs Node (Vercel)

middleware.ts runs on the Edge. Importing auth from @/lib/auth pulled in Prisma Client and bcrypt, which inflated the middleware bundle past Vercel Hobby’s ~1 MB Edge Function limit and caused deploy failures after a successful build.

The fix is to initialize middleware with only authConfig (JWT/session logic, empty providers array — credentials run on the server only):

// middleware.ts (project root)
import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import { authConfig } from "@/lib/auth.config";

export default NextAuth(authConfig).auth((req) => {
  /* redirect unauthenticated /admin/* → /admin/login, etc. */
});

export const config = {
  matcher: ["/admin/:path*"],
};

Sign-in and API routes still use src/lib/auth.ts (Node/serverless) where Prisma and bcrypt are appropriate.

Credentials Provider

The Credentials provider accepts email and password. It queries the database for a user with the given email, then verifies the password with bcrypt.compare().

OAuth Providers (Google & GitHub)

src/lib/auth.ts also supports Google and GitHub providers. They are enabled only if these env vars are present:

For UI gating, the login/signup forms read public flags:

On first successful OAuth sign-in, the signIn callback checks whether the user exists by email; if not, it creates a user row automatically with a generated bcrypt-hashed password. This keeps credentials and social accounts in one user table.

JWT & Session Callbacks

JWT Callback

The JWT callback keeps id, role, email, and name in sync with the database. This ensures Credentials and OAuth users both get a normalized token payload.

jwt({ token, user }) {
  const tokenEmail = (user?.email ?? token.email)?.toLowerCase();
  if (tokenEmail && (user || !token.id || !token.role)) {
    const dbUser = await prisma.user.findUnique({ where: { email: tokenEmail } });
    if (dbUser) {
      token.id = dbUser.id;
      token.role = dbUser.role;
      token.email = dbUser.email;
      token.name = dbUser.name;
    }
  }
  return token;
}

Session Callback

The session callback copies role and id from the JWT token into the session object, making them available in client components via useSession().

Auth Middleware

The middleware at middleware.ts (project root) protects all /admin/* routes:

It uses NextAuth(authConfig).auth(...) so the Edge bundle contains only Auth.js JWT handling — not Prisma or bcrypt. Do not switch this back to import { auth } from "@/lib/auth" without checking middleware size on Vercel.

Note: Session validation in middleware reads the encrypted JWT cookie using the same NEXTAUTH_SECRET and callbacks as auth.ts.

Admin Layout

The admin layout (src/app/admin/layout.tsx) wraps all admin pages with:

Login Page

The login page at /admin/login is split into two components:

The Suspense boundary is required because useSearchParams() must be wrapped in Suspense to avoid SSR warnings in the App Router (Next.js 15+).

Signup & Register API

The signup page at /admin/signup mirrors the login UX and adds first-name/last-name fields, password confirmation, and a password-strength indicator. It supports both email signup and social signup buttons (Google/GitHub) when enabled.

/api/auth/register validates names/email/password, blocks duplicate emails, hashes passwords with bcrypt, and creates a user in Prisma. After successful registration, the client attempts immediate credentials sign-in and redirects to the callback URL or admin dashboard.

AuthProvider

src/components/providers/AuthProvider.tsx is a "use client" component that wraps children with NextAuth's SessionProvider. It's used in the admin layout so all admin pages can access session data via useSession().

Authentication Flow

Email login/signup flow
1. User visits /admin → middleware redirects to /admin/login
2. User enters email + password → LoginForm calls signIn("credentials")
3. NextAuth Credentials provider → prisma.user.findUnique → bcrypt.compare
4. On success → JWT created with { role, id } → redirect to /admin
5. Subsequent requests → Edge middleware validates JWT (no DB) → allows access
6. AdminSidebar reads useSession() → shows user info, sign-out button
OAuth flow (Google/GitHub)
1. User clicks Google/GitHub on /admin/login or /admin/signup
2. NextAuth redirects to OAuth provider and back to callback
3. signIn callback checks user by email in Prisma
4. If user does not exist → create user row (generated bcrypt hash)
5. JWT/session callbacks hydrate { id, role, email, name } from database
6. User is redirected to callbackUrl or /admin