Next-Auth Setup for SaaS: Credentials, OAuth, and Session Strategy
A production guide for wiring Auth.js (next-auth v5) in a Next.js SaaS—covering providers, protected routes, role enforcement, and session configuration.
Contents
Auth.js (formerly next-auth) is the most common authentication library for Next.js SaaS apps. It handles OAuth providers, credentials, sessions, and JWT out of the box. The setup is fast—but the defaults aren't production-ready for SaaS.
This guide covers what to change: database sessions over JWTs for long-lived apps, role-based access, protected routes via middleware, and the exact edge cases that break after launch.
What Auth.js Handles vs. What You Own
Auth.js manages: OAuth flow, session creation, CSRF tokens, sign-in/sign-out routes.
You own: user roles, plan enforcement, session data beyond the default fields, and any custom login UX.
Don't expect Auth.js to gate features by subscription plan. That's your application logic.
Setup
1. Install
bun add next-auth@beta @auth/drizzle-adapter2. Auth Configuration
src/auth.tsimport NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID!,
clientSecret: process.env.AUTH_GITHUB_SECRET!,
}),
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
}),
],
session: {
strategy: "database", // Prefer over JWT for SaaS
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
async session({ session, user }) {
// Extend session with role and plan from DB
session.user.id = user.id;
session.user.role = user.role;
session.user.plan = user.plan;
return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
});Database sessions vs JWT
Use strategy: "database" for SaaS apps. Database sessions let you invalidate sessions server-side (on password change, account ban, plan downgrade). JWT sessions can't be revoked without a blocklist—which removes their stateless advantage anyway.
3. Route Handler
src/app/api/auth/[...nextauth]/route.tsimport { handlers } from "@/auth";
export const { GET, POST } = handlers;4. Middleware for Protected Routes
src/middleware.tsimport { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { nextUrl, auth: session } = req;
const isLoggedIn = !!session?.user;
// Routes requiring authentication
const protectedPaths = ["/dashboard", "/settings", "/billing"];
const isProtected = protectedPaths.some((path) =>
nextUrl.pathname.startsWith(path)
);
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", nextUrl));
}
// Admin routes requiring role check
if (nextUrl.pathname.startsWith("/admin") && session?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Middleware runs on the Edge
Middleware runs on the Edge runtime. Keep it lightweight. Don't import Drizzle or heavy Node.js modules. Read only from the session, not the database.
5. Extend the Session Types
TypeScript won't know about role or plan unless you extend the types:
// src/types/next-auth.d.ts
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "user" | "admin";
plan: "free" | "pro" | "enterprise";
} & DefaultSession["user"];
}
interface User {
role: "user" | "admin";
plan: "free" | "pro" | "enterprise";
}
}6. Accessing Session in Server Components
import { auth } from "@/auth";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return <Dashboard user={session.user} />;
}7. Accessing Session in Client Components
"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") return <Spinner />;
if (!session) return <SignInButton />;
return <span>{session.user.email}</span>;
}Database Schema for Auth.js
Auth.js v5 with Drizzle expects specific tables. The adapter creates them, but you'll want to add your custom columns:
// drizzle/schema.ts (Auth.js tables + custom fields)
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("user", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
// Custom columns
role: text("role").notNull().default("user"),
plan: text("plan").notNull().default("free"),
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at").defaultNow(),
});Common Failure Modes
Session not extending with custom fields
You added fields to the session callback but they're not appearing. Check that you've also extended the TypeScript types (next-auth.d.ts). Type errors here are silent at runtime.
OAuth redirect URI mismatch
The redirect URI in your OAuth app settings must exactly match your deployed URL. http://localhost:3000/api/auth/callback/github for local, https://yourdomain.com/api/auth/callback/github for production. They are different registrations.
Middleware blocking API routes
The matcher in middleware.ts must exclude /api/auth/... or Auth.js's own routes will be blocked. The pattern /((?!api|_next/static...) handles this correctly.
Accounts not linking across providers
If a user signs in with Google, then with GitHub using the same email, Auth.js does not auto-link by default. Set allowDangerousEmailAccountLinking: true only if you've verified emails first, or implement explicit account linking.
Tradeoffs
| Approach | Pro | Con |
|---|---|---|
| Database sessions | Server-side revocation, larger payloads in DB | DB hit on every request |
| JWT sessions | No DB on auth check | Can't revoke without blocklist |
| Credentials provider | Full control over login flow | You own password hashing, rate limiting, lockout |
| OAuth only | Simpler security surface | Users need Google/GitHub/etc. |
For SaaS, OAuth-first with database sessions is the safest default.
Verification
- Protected routes return 302 to
/loginwhen unauthenticated (test in incognito). - Admin routes return 302 to
/dashboardfor non-admin users. - Session contains
roleandplanafter login. - Logging out invalidates the session in the DB.
- Email address from OAuth is stored correctly in the
usertable.
Next Steps
- Auth + Billing Launch Checklist — gate your release on auth correctness.
- How to Handle Stripe Webhooks in Next.js — wire billing after auth is solid.
- Explore ShipAI's built-in auth integration with role management and admin panel out of the box.