Back to Blog

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.

Viprasol Tech Team
March 1, 2027
14 min read

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:

JWTDatabase Session
DB query on every requestNo (fast)Yes (1 query)
Revoke session immediatelyโŒ (wait for expiry)โœ… (delete from DB)
Scaleโœ… StatelessNeeds session DB
Rotate refresh tokensManualBuilt-in
Best forPublic APIs, edgeSaaS (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

ComponentTimelineCost (USD)
Auth.js v5 setup (OAuth + email)1โ€“2 days$800โ€“$1,600
Magic link email0.5 day$300โ€“$500
Middleware protection0.5 day$300โ€“$500
Role-based access0.5โ€“1 day$400โ€“$800
Full auth system1โ€“2 weeks$5,000โ€“$10,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.