All PostsMar 3, 2026ShipAI Team1 min read

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.

AuthNext.jsNext-AuthSecurity

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-adapter

2. Auth Configuration

src/auth.ts
import 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.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;

4. Middleware for Protected Routes

src/middleware.ts
import { 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

ApproachProCon
Database sessionsServer-side revocation, larger payloads in DBDB hit on every request
JWT sessionsNo DB on auth checkCan't revoke without blocklist
Credentials providerFull control over login flowYou own password hashing, rate limiting, lockout
OAuth onlySimpler security surfaceUsers need Google/GitHub/etc.

For SaaS, OAuth-first with database sessions is the safest default.

Verification

  • Protected routes return 302 to /login when unauthenticated (test in incognito).
  • Admin routes return 302 to /dashboard for non-admin users.
  • Session contains role and plan after login.
  • Logging out invalidates the session in the DB.
  • Email address from OAuth is stored correctly in the user table.

Next Steps

Ready to ship?

Stop rebuilding auth and billing from scratch.

ShipAI.today gives you a production-ready Next.js foundation. Every module pre-integrated — spend your time building your product, not plumbing.

Full source code · Commercial license · Lifetime updates