Next.js Authentication Patterns in 2026: Auth.js v5, JWT vs Session, OAuth, and Magic Links
Implement Next.js authentication with Auth.js v5: database sessions vs JWT, OAuth providers (Google, GitHub), magic link email, middleware protection, and role-based access.
Next.js Authentication Patterns in 2026: Auth.js v5, JWT vs Session, OAuth, and Magic Links
Authentication is the first thing you build and the last thing you want to get wrong. Auth.js v5 (formerly NextAuth) is the standard for Next.js App Router authentication โ it handles OAuth, email magic links, credentials, and supports both JWT and database session strategies with a unified API.
This post covers Auth.js v5 configuration, the tradeoffs between JWT and database sessions, Google and GitHub OAuth setup, magic link email authentication, middleware protection, and adding role-based access on top.
Installation
npm install next-auth@beta @auth/prisma-adapter
# Or with Drizzle: @auth/drizzle-adapter
Core Configuration
// auth.ts (root of project)
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Resend from "next-auth/providers/resend";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { db } from "@/lib/db";
export const { handlers, signIn, signOut, auth } = NextAuth({
// Database adapter: required for database sessions and email magic links
adapter: PrismaAdapter(db),
// Session strategy:
// "jwt" = stateless, no DB lookup on every request (faster, scales well)
// "database" = session stored in DB, can revoke instantly (more secure)
session: { strategy: "database" },
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
// Request refresh token (for accessing Google APIs on behalf of user)
authorization: {
params: { access_type: "offline", prompt: "consent" },
},
}),
GitHub({
clientId: process.env.AUTH_GITHUB_ID!,
clientSecret: process.env.AUTH_GITHUB_SECRET!,
}),
Resend({
// Magic link via Resend email
apiKey: process.env.AUTH_RESEND_KEY!,
from: "Viprasol <auth@viprasol.com>",
}),
],
callbacks: {
// Enrich session with custom fields (role, workspaceId)
async session({ session, user }) {
if (user) {
session.user.id = user.id;
session.user.role = user.role as string;
session.user.workspaceId = user.currentWorkspaceId as string;
}
return session;
},
// Control who can sign in
async signIn({ user, account }) {
// Block signups from certain domains (allowlist)
if (process.env.AUTH_ALLOWED_DOMAINS) {
const allowed = process.env.AUTH_ALLOWED_DOMAINS.split(",");
const domain = user.email?.split("@")[1];
if (domain && !allowed.includes(domain)) return false;
}
return true;
},
// Control redirect after sign in
async redirect({ url, baseUrl }) {
if (url.startsWith("/")) return `${baseUrl}${url}`;
if (new URL(url).origin === baseUrl) return url;
return `${baseUrl}/dashboard`;
},
},
pages: {
signIn: "/login",
signOut: "/logout",
error: "/login", // Redirect errors to login page
verifyRequest: "/login?verify=true", // Magic link sent page
},
});
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Prisma Schema for Auth.js
// prisma/schema.prisma โ Auth.js required tables
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
role String @default("member")
currentWorkspaceId String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
model Session {
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String
expires DateTime
@@id([identifier, token])
}
Route Handlers
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
JWT Strategy (Stateless)
Use JWT when you don't have a database or need horizontal scale without session DB:
// auth.ts โ JWT variant
export const { handlers, signIn, signOut, auth } = NextAuth({
// No adapter needed for JWT
session: { strategy: "jwt" },
providers: [Google({ /* ... */ }), GitHub({ /* ... */ })],
callbacks: {
async jwt({ token, user, account }) {
// On initial sign-in, user and account are available
if (user) {
token.id = user.id;
token.role = user.role ?? "member";
}
// On subsequent requests, only token is available
return token;
},
async session({ session, token }) {
session.user.id = token.id as string;
session.user.role = token.role as string;
return session;
},
},
});
JWT vs Database session tradeoffs:
| JWT | Database Session | |
|---|---|---|
| DB query on every request | No (fast) | Yes (1 query) |
| Revoke session immediately | โ (wait for expiry) | โ (delete from DB) |
| Scale | โ Stateless | Needs session DB |
| Rotate refresh tokens | Manual | Built-in |
| Best for | Public APIs, edge | SaaS (can revoke on logout/ban) |
TypeScript: Type-Safe Session
// types/next-auth.d.ts โ extend default types
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name?: string | null;
image?: string | null;
role: string;
workspaceId?: string;
};
}
interface User {
role: string;
currentWorkspaceId?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
}
}
Server Component Authentication
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
);
}
Middleware Protection
// middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { nextUrl, auth: session } = req;
const isLoggedIn = !!session;
const isAuthPage = nextUrl.pathname.startsWith("/login") ||
nextUrl.pathname.startsWith("/signup");
const isPublic = nextUrl.pathname === "/" ||
nextUrl.pathname.startsWith("/blog") ||
nextUrl.pathname.startsWith("/pricing");
// Redirect authenticated users away from auth pages
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
// Redirect unauthenticated users to login
if (!isPublic && !isAuthPage && !isLoggedIn) {
const loginUrl = new URL("/login", nextUrl);
loginUrl.searchParams.set("callbackUrl", nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|public/).*)"],
};
Magic Link (Email Authentication)
// components/auth/MagicLinkForm.tsx
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
export function MagicLinkForm({ callbackUrl = "/dashboard" }: { callbackUrl?: string }) {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
const result = await signIn("resend", {
email,
callbackUrl,
redirect: false,
});
setLoading(false);
if (result?.error) {
setError("Failed to send magic link. Please try again.");
} else {
setSent(true);
}
};
if (sent) {
return (
<div className="text-center space-y-2">
<div className="text-4xl">๐ฌ</div>
<h3 className="font-semibold text-gray-900">Check your email</h3>
<p className="text-sm text-gray-500">
We sent a magic link to <strong>{email}</strong>. Click the link to sign in.
</p>
<button
onClick={() => setSent(false)}
className="text-sm text-blue-600 underline"
>
Use a different email
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email address
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Sending..." : "Send magic link"}
</button>
</form>
);
}
Role-Based Access Control
// lib/auth/require-role.ts
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export type UserRole = "owner" | "admin" | "member" | "viewer";
const ROLE_HIERARCHY: Record<UserRole, number> = {
owner: 4,
admin: 3,
member: 2,
viewer: 1,
};
export async function requireRole(
minimumRole: UserRole,
redirectTo = "/dashboard"
) {
const session = await auth();
if (!session?.user) redirect("/login");
const userRank = ROLE_HIERARCHY[session.user.role as UserRole] ?? 0;
const requiredRank = ROLE_HIERARCHY[minimumRole];
if (userRank < requiredRank) redirect(redirectTo);
return session;
}
// Usage in Server Component:
export default async function BillingPage() {
const session = await requireRole("admin");
// Only admins and owners reach here
return <BillingUI userId={session.user.id} />;
}
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| Auth.js v5 setup (OAuth + email) | 1โ2 days | $800โ$1,600 |
| Magic link email | 0.5 day | $300โ$500 |
| Middleware protection | 0.5 day | $300โ$500 |
| Role-based access | 0.5โ1 day | $400โ$800 |
| Full auth system | 1โ2 weeks | $5,000โ$10,000 |
See Also
- Next.js Middleware Authentication โ Custom JWT middleware (without Auth.js)
- SaaS Role-Based Access โ Fine-grained RBAC beyond roles
- SaaS Multi-Workspace โ Per-workspace session context
- SaaS Team Invitations โ Invitation-based sign-up flow
Working With Viprasol
We implement production-grade Next.js authentication for SaaS products โ from simple OAuth sign-in through multi-provider flows with magic links, role-based access, and workspace context. Our team has shipped Auth.js v5 setups handling tens of thousands of active sessions.
What we deliver:
- Auth.js v5 configuration with Google, GitHub, and Resend providers
- JWT vs database session strategy recommendation for your use case
- TypeScript type extensions for custom session fields
- Middleware protection with public/auth/protected path configuration
- Role hierarchy and requireRole server component helper
Explore our web development services or contact us to implement authentication for your Next.js application.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.