Every SaaS dashboard, billing page, and Stripe Customer Portal route needs to knowwhois logged in.Auth.js with the Prisma adapter and credentials login in Next.js 16gives you email/password auth without a separate auth microservice: sessions live in your database, Server Components callauth(), and Route Handlers stay aligned with the App Router.
This guide wiresAuth.js (v5) +@auth/prisma-adapter+ Credentials providerend to end Prisma models,lib/auth.ts, the catch-all auth route, register flow with bcrypt, and reading the session in layouts. Once this works, you can protect routes with middleware (next in this series) and connect billing flows fromStripe subscription syncand theCustomer Portalto a realuserId.
What you are building
Piece Responsibility PrismaUser,Account,SessionAuth.js database session storageCredentialsprovider Email + password login via bcryptapp/api/auth/[...nextauth]/route.tsAuth.js HTTP handlers Register Server Action / API Create user with hashed passwordauth()in Server Components Load session for dashboard UI
OAuth (Google, GitHub) can be added later as extra providers on the sameauth.tsconfig. Credentials do not use theAccountrow for OAuth tokens you storepasswordHashonUser(or a dedicated table) and validate inauthorize().
Prerequisites
Next.js 16 (or 15+) with App Router.
Prisma + PostgreSQL.
Node.js runtime for bcrypt (not Edge on the credentials authorize path).
Step 1: Install packages
npm install next-auth@beta @auth/prisma-adapter bcryptjs
npm install -D @types/bcryptjsAuth.js v5 is published asnext-auth@betawith exports fromnext-authandnext-auth/react. Pin versions in production lockfiles after you verify compatibility with your Next.js release.
Generate a secret:
npx auth secretAdd to.env:
AUTH_SECRET=... # output from auth secret
DATABASE_URL=postgresql://...AUTH_SECRETencrypts session tokens and cookies. Rotate it only with a plan to invalidate existing sessions.
For production on Vercel, setAUTH_URLor rely ontrustHost: trueso callback URLs resolve to your deployed hostname. Keep secrets in the Vercel project settings—not in client bundles.
Step 2: Prisma schema for Auth.js + SaaS fields
Use the standard Auth.js adapter models and extendUserfor billing later:
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
passwordHash String?
stripeCustomerId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
subscriptions Subscription[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Subscription {
id String @id @default(cuid())
userId String
stripeSubscriptionId String @unique
stripePriceId String
status String
currentPeriodEnd DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}passwordHashisnotpart of the default Auth.js schema—you add it for credentials-only SaaS. Never expose it to the client or serialize it in session callbacks.
Runnpx prisma migrate dev --name auth-js-init.
Step 3: Configure Auth.js with PrismaAdapter
Createlib/auth.ts:
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: "database" },
pages: {
signIn: "/login",
},
providers: [
Credentials({
name: "Email and password",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email =
typeof credentials?.email === "string"
? credentials.email.trim().toLowerCase()
: "";
const password =
typeof credentials?.password === "string" ? credentials.password : "";
if (!email || !password) return null;
const user = await db.user.findUnique({ where: { email } });
if (!user?.passwordHash) return null;
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
session({ session, user }) {
if (session.user) {
session.user.id = user.id;
}
return session;
},
},
});Database sessions vs JWT:For a multi-tab SaaS dashboard,strategy: "database"lets you revoke sessions in Prisma and keeps session size small. JWT is fine for stateless APIs; most Next.js SaaS kits use database sessions with the adapter.
Extend TypeScript forsession.user.id:
// types/next-auth.d.ts
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
email?: string | null;
name?: string | null;
image?: string | null;
};
}
}Step 4: Auth Route Handler (App Router)
Createapp/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Next.js 16 serves these on/api/auth/*for sign-in, sign-out, CSRF, and session endpoints. Do not wrap this path in body parsers that consume the stream before Auth.js runs.
Step 5: Register flow with bcrypt
Credentials provider does not register users you create them explicitly.
app/actions/register.ts(Server Action example):
"use server";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { signIn } from "@/lib/auth";
export async function registerUser(formData: FormData) {
const email = String(formData.get("email") ?? "")
.trim()
.toLowerCase();
const password = String(formData.get("password") ?? "");
const name = String(formData.get("name") ?? "").trim() || null;
if (!email || password.length < 8) {
return { error: "Invalid email or password too short." };
}
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return { error: "An account with this email already exists." };
}
const passwordHash = await bcrypt.hash(password, 12);
await db.user.create({
data: {
email,
name,
passwordHash,
},
});
await signIn("credentials", { email, password, redirectTo: "/dashboard" });
}app/register/page.tsxposts to this action; on success,signIncreates aSessionrow via the adapter.
Security:enforce minimum password length, consider zxcvbn or Have I Been Pwned later, and rate-limit login attempts at the edge or in middleware.
Step 6: Login page (client signIn)
app/login/page.tsx:
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const form = new FormData(e.currentTarget);
const result = await signIn("credentials", {
email: form.get("email"),
password: form.get("password"),
redirect: false,
});
if (result?.error) {
setError("Invalid email or password.");
return;
}
window.location.href = "/dashboard";
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required autoComplete="email" />
<input name="password" type="password" required autoComplete="current-password" />
{error && <p role="alert">{error}</p>}
<button type="submit">Sign in</button>
</form>
);
}Wrap the app withSessionProvideronly if you need client session hooks outside login; Server Components can useauth()without it.
Step 7: Read session in Server Components
Protect dashboard content at the page or layout level:
// app/dashboard/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<header>Signed in as {session.user.email}</header>
{children}
</div>
);
}For route-level protection across many paths, addAuth.js middleware(Week 6 in this series) instead of repeatingredirectin every layout.
Billing tie-in:Portal and Checkout handlers should callauth()and usesession.user.idwhen creating Stripe Checkout sessions and readingstripeCustomerIdsame pattern as theCustomer Portal route handler.
Step 8: Credentials + database sessions quirk
Withstrategy: "database", successfulsignIn("credentials", ...)must create a session linked to the user. Auth.js v5 handles this whenauthorizereturns a user object withid. If sessions never appear in Prisma:
Confirm
authorizereturns non-null with a validid.Confirm
Sessionmodel matches adapter expectations.Check cookies:
AUTH_SECRET, HTTPS in production, andtrustHost: trueif behind proxies (Vercel):
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
// ...rest
});Step 9: Test checklist
Register a new user — row in
Userwith non-nullpasswordHash, no plaintext password in logs.Sign in — new row in
Session, cookie set,/dashboardrenders email.Sign out — session removed,
/dashboardredirects to login.Wrong password — generic error (do not reveal whether email exists).
Stripe path — logged-in user can hit portal route with
session.user.id(after you wire Stripe from articles 01–04).
Local webhook testing still usesStripe CLI; auth is independent but required before testing billing UI as a real user.
Common failures
“CredentialsSignin” or immediate redirect back to login
authorizereturnednull(wrong password, missingpasswordHash, or email case mismatch). Normalize email to lowercase on register and login.
Session exists butsession.user.idis undefined
Add thesessioncallback that copiesuser.idfrom the database user (Step 3) and extend the TypeScript module.
bcrypt fails on Edge runtime
Keep credentials authorization on Node.js (export const runtime = "nodejs"on auth route if needed). Do not runbcrypt.comparein Edge middleware.
Adapter migration errors
Runnpx @auth/prisma-adapterschema docs or copy official models exactly—field renames break session creation silently.
Users can access/dashboardwithout login during dev
Layout guard missing or middleware matcher too narrow. Fix in layout first, then centralize in middleware.
Can I use Auth.js without the Prisma adapter?
Yes JWT-only auth skipsSessiontables but you lose adapter-managed OAuth linking and server-side session revocation. For a typical Next.js SaaS dashboard, the Prisma adapter plus database sessions is the path of least regret.
Auth + billing in one kit
Auth.js, Prisma, Stripe Checkout, webhooks, portal, and dashboard layouts are the same foundation every Next.js SaaS rebuilds.
Skryptix AI SaaS Starter Kitincludes:
Auth.js with Prisma adapter, login/register, and optional OAuth hooks
Stripe Checkout, Customer Portal, and subscription sync
Dashboard, settings, plan gates, teams, and AI usage stubs
Deploy and env documentation for Vercel
AI SaaS Starter Kitincludes Auth.js, email flows, and optional OAuth —Get it here.
FAQ
How do I use Auth.js with the Prisma adapter in Next.js 16?
Installnext-authand@auth/prisma-adapter, add Auth.js models toschema.prisma, configureNextAuth({ adapter: PrismaAdapter(db), session: { strategy: "database" } }), and exporthandlersfromapp/api/auth/[...nextauth]/route.ts.
How do I add email and password login with Auth.js?
Register aCredentialsprovider with anauthorizefunction that loads the user by email, comparesbcrypthashes, and returns a user object. Create users in a separate register action withbcrypt.hashstored onUser.passwordHash.
Should SaaS apps use JWT or database sessions with Auth.js?
Database sessions with the Prisma adapter are a strong default for dashboard SaaS: revocation, consistentuserIdin Server Components, and straightforward session listing for support. JWT suits edge-only or mobile API backends.
Where does Auth.js fit with Stripe billing in Next.js?
After login, useauth()in Route Handlers to getsession.user.id, attach it to Checkout metadata, and storestripeCustomerIdon the sameUserrow. Webhooks updateSubscription; portal routes require an authenticated session.
What env vars do I need for Auth.js on Vercel?
At minimumAUTH_SECRETandDATABASE_URL. Add Stripe and OAuth secrets per provider; see theNext.js SaaS env checklistin this series.
Written by the team behind Skryptix. We build the AI SaaS Starter Kit so founders can focus on product not auth and billing infrastructure.
