<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged authjs</title>
<link>https://coderlegion.com/tag/authjs</link>
<description>Powered by Question2Answer</description>
<item>
<title>Auth.js vs Better Auth in 2026: Which Should You Use?</title>
<link>https://coderlegion.com/25814/auth-js-vs-better-auth-in-2026-which-should-you-use</link>
<description>&lt;p&gt;You're starting a new Next.js project and you need auth. A year ago the answer was simple: &quot;just use NextAuth.&quot; That's not simple anymore. NextAuth is now called Auth.js, it's sitting in maintenance mode, and the team that used to build it has folded into a different project entirely — &lt;strong&gt;Better Auth&lt;/strong&gt;, which Vercel just bought.&lt;/p&gt;
&lt;p&gt;If you Google &quot;Auth.js vs Better Auth&quot; today, half the results are outdated and the other half assume you already know the backstory. You don't need the backstory to make a good decision — you just need the current facts, side by side, with real code. That's what this guide gives you.&lt;/p&gt;
&lt;p&gt;By the end, you'll know exactly what each library does well, what each one is missing, and which one fits your project — whether that's a solo side project, a client SaaS app, or something you're migrating from an old NextAuth v4 codebase.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-comparison-is-confusing-right-now&quot;&gt;Why This Comparison Is Confusing Right Now&lt;/h3&gt;
&lt;p&gt;Three things changed in a short window, and most tutorials online haven't caught up:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;NextAuth renamed itself Auth.js&lt;/strong&gt; when it expanded beyond Next.js to support other frameworks. The npm package is still called &lt;code&gt;next-auth&lt;/code&gt; — only the project's public name changed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth.js is now maintained by Better Auth Inc.&lt;/strong&gt;, not its original team. Since early 2026, Auth.js has been in maintenance mode: security patches only, no new features.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel acquired Better Auth&lt;/strong&gt; on July 7, 2026. Better Auth stays free, MIT-licensed, and open source — Vercel says it isn't closing that off — but it's now the auth library with Vercel's own resources and roadmap behind it, not a scrappy independent project.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Put simply: if you pick Auth.js today, you're picking a stable, feature-frozen library. If you pick Better Auth, you're picking the actively developed one that absorbed Auth.js's own team.&lt;/p&gt;
&lt;p&gt;That doesn't automatically make Better Auth the right answer for every project — &quot;actively developed&quot; and &quot;right for you&quot; aren't the same thing. Let's get into specifics.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer&quot;&gt;Quick Answer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Choose Auth.js (NextAuth)&lt;/strong&gt; if you're maintaining an existing NextAuth v4/v5 codebase, you only need OAuth + JWT sessions with zero extra features, or you want the library with the longest production track record.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Better Auth&lt;/strong&gt; if you're starting a new project today and want built-in passkeys, 2FA, organizations, or RBAC without hand-rolling them, or you want a library that's still receiving new features.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a brand-new Next.js project in 2026 with no existing NextAuth investment, Better Auth is the stronger default. Now here's why.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;                         AUTH.JS
                            │
                  ┌─────────┴─────────┐
                  │                   │
             JWT sessions        DB sessions
                  │                   │
             no DB required       DB required


                      BETTER AUTH
                           │
              ┌────────────┴────────────┐
              │                         │
        Stateless mode            Database mode
              │                         │
          no DB required          DB-backed sessions
                                        │
                           ┌────────────┼────────────┐
                           │            │            │
                       Passkeys       2FA      Organizations
                                      │            │
                                  DB-backed    DB-backed
                                  features     features
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-each-library-actually-is&quot;&gt;What Each Library Actually Is&lt;/h3&gt;
&lt;h4 id=&quot;auth-js-formerly-nextauth-&quot;&gt;Auth.js (formerly NextAuth)&lt;/h4&gt;
&lt;p&gt;Auth.js is a session and OAuth layer built specifically around Next.js's App Router conventions. You configure a single &lt;code&gt;auth.ts&lt;/code&gt; file, wire it into a catch-all route handler, and call &lt;code&gt;auth()&lt;/code&gt; anywhere on the server to read the current session.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install next-auth@beta
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Running &lt;code&gt;npm install next-auth&lt;/code&gt; without the &lt;code&gt;@beta&lt;/code&gt; tag installs the old v4 API (&lt;code&gt;authOptions&lt;/code&gt;, &lt;code&gt;getServerSession()&lt;/code&gt;, a completely different setup). As of mid-2026, &lt;code&gt;next-auth@latest&lt;/code&gt; still resolves to v4 — v5 is only reachable through the &lt;code&gt;beta&lt;/code&gt; tag, even though it's what almost every current tutorial (including this one) assumes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// auth.ts
import NextAuth from &quot;next-auth&quot;;
import GitHub from &quot;next-auth/providers/github&quot;;

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/auth/[...nextauth]/route.ts
import { handlers } from &quot;@/auth&quot;;
export const { GET, POST } = handlers;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's a complete, working GitHub OAuth setup — no database required if you're fine with JWT sessions. &lt;code&gt;auth()&lt;/code&gt; is now the single entry point for reading the session anywhere on the server, replacing the old v4 pattern of &lt;code&gt;getServerSession(authOptions)&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;better-auth&quot;&gt;Better Auth&lt;/h4&gt;
&lt;p&gt;Better Auth is framework-agnostic (Next.js, Nuxt, SvelteKit, TanStack Start, plain Node) and TypeScript-first. Instead of one library that does OAuth and sessions, it ships a small core plus a plugin system — passkeys, 2FA, organizations, and RBAC are opt-in plugins, not separate libraries you bolt on yourself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install better-auth
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth.ts
import { betterAuth } from &quot;better-auth&quot;;
import { drizzleAdapter } from &quot;better-auth/adapters/drizzle&quot;;
import { db } from &quot;@/lib/db&quot;;

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: &quot;pg&quot; }),
  emailAndPassword: {
    enabled: true,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/auth/[...all]/route.ts
import { auth } from &quot;@/lib/auth&quot;;
import { toNextJsHandler } from &quot;better-auth/next-js&quot;;

export const { POST, GET } = toNextJsHandler(auth);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;toNextJsHandler()&lt;/code&gt; is Better Auth's official helper for wiring its core into a Next.js App Router catch-all route — it maps every request under &lt;code&gt;/api/auth/*&lt;/code&gt; to the right internal handler, the same way Auth.js's &lt;code&gt;handlers&lt;/code&gt; export does.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; The folder is &lt;code&gt;app/api/auth/[...all]/route.ts&lt;/code&gt;, not &lt;code&gt;[...nextauth]&lt;/code&gt;. Better Auth's catch-all segment name is arbitrary — it just needs to match whatever &lt;code&gt;basePath&lt;/code&gt; you configure on the client (the default is &lt;code&gt;/api/auth&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;better-auth-can-also-run-without-a-database-now&quot;&gt;Better Auth Can Also Run Without a Database Now&lt;/h3&gt;
&lt;p&gt;For a long time, &quot;no database, no Better Auth&quot; was the biggest practical reason to reach for Auth.js instead. That gap closed with &lt;strong&gt;Better Auth 1.4&lt;/strong&gt; (November 2025): you can now omit the &lt;code&gt;database&lt;/code&gt; option entirely and run fully stateless, signed-cookie sessions — the same JWT-style model Auth.js has always defaulted to.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth.ts — stateless, no database at all
import { betterAuth } from &quot;better-auth&quot;;

export const auth = betterAuth({
  // No `database` option — session data lives in a signed/encrypted cookie
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; with no &lt;code&gt;database&lt;/code&gt; configured, Better Auth verifies session cookies by signature and expiry instead of looking them up in a table — functionally the same trade-off as Auth.js's JWT sessions (fast, no DB round trip, but no server-side &quot;delete this one session&quot; revocation).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Assuming stateless mode unlocks the full plugin ecosystem too. Better Auth's own docs are explicit that &lt;strong&gt;most plugins still require a database&lt;/strong&gt; — &lt;code&gt;passkey()&lt;/code&gt;, &lt;code&gt;organization()&lt;/code&gt;, and two-factor auth all persist real records (credentials, memberships, invitations) that a signed cookie can't hold. Stateless mode covers basic email/password and OAuth sign-in; reach for a database the moment you add a plugin.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So the database gap between the two libraries has narrowed for basic auth, but it's still real the moment you want any of Better Auth's headline plugin features.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup-time&quot;&gt;Setup Time&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Auth.js&lt;/strong&gt;, with JWT sessions and one OAuth provider, is genuinely the fastest path to a working sign-in flow — install, create &lt;code&gt;auth.ts&lt;/code&gt;, add the route handler, done. No database, no schema migration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Better Auth&lt;/strong&gt;, with a database adapter (Drizzle, Prisma, Kysely, or a handful of others), needs one extra step — generating and applying the schema — before it will run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx auth@latest generate
npx auth@latest migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; These commands used to live under a separate &lt;code&gt;@better-auth/cli&lt;/code&gt; package. As of 2026 the CLI is published as the &lt;code&gt;auth&lt;/code&gt; package on npm and invoked with &lt;code&gt;npx auth@latest &amp;lt;command&amp;gt;&lt;/code&gt; — if you see &lt;code&gt;npx @better-auth/cli generate&lt;/code&gt; in an older tutorial, it's the same tool under its previous name.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's a few extra minutes of setup compared to Auth.js's JWT mode — but it's also the reason a database-backed Better Auth setup gets you a real user table, instant session revocation, and its plugin ecosystem for free once it's running. If you don't want the database at all, &lt;a rel=&quot;nofollow&quot; href=&quot;#better-auth-can-also-run-without-a-database-now&quot;&gt;Better Auth's stateless mode&lt;/a&gt; closes most of that setup-time gap for basic email/password and OAuth sign-in — the schema migration step above is only needed once you configure a &lt;code&gt;database&lt;/code&gt; or add a plugin that requires one.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Even without your own feature needs pointing you one way or the other, this is still a reasonable rule of thumb: default to Auth.js (or Better Auth's stateless mode) for a genuinely tiny side project, and to a database-backed Better Auth setup for anything that will eventually need passkeys, 2FA, organizations, or persistent user records.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;reading-the-session&quot;&gt;Reading the Session&lt;/h3&gt;
&lt;h4 id=&quot;auth-js-server-component&quot;&gt;Auth.js — Server Component&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { auth } from &quot;@/auth&quot;;

export default async function DashboardPage() {
  const session = await auth();

  return &amp;lt;h1&amp;gt;Welcome back, {session?.user?.name}&amp;lt;/h1&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;better-auth-server-component&quot;&gt;Better Auth — Server Component&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { auth } from &quot;@/lib/auth&quot;;
import { headers } from &quot;next/headers&quot;;

export default async function DashboardPage() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  return &amp;lt;h1&amp;gt;Welcome back, {session?.user?.name}&amp;lt;/h1&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's different here:&lt;/strong&gt; Auth.js's &lt;code&gt;auth()&lt;/code&gt; reads cookies internally with no extra arguments. Better Auth's server-side session check is a plain API method (&lt;code&gt;auth.api.getSession&lt;/code&gt;) that you explicitly pass request headers into — a small but real difference in ergonomics, since you have to remember the &lt;code&gt;headers()&lt;/code&gt; call every time.&lt;/p&gt;
&lt;h4 id=&quot;better-auth-client-component&quot;&gt;Better Auth — Client Component&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/user-menu.tsx
&quot;use client&quot;;

import { authClient } from &quot;@/lib/auth-client&quot;;

export function UserMenu() {
  const { data: session, isPending } = authClient.useSession();

  if (isPending) return null;
  if (!session) return &amp;lt;a href=&quot;/sign-in&quot;&amp;gt;Sign in&amp;lt;/a&amp;gt;;

  return &amp;lt;span&amp;gt;Welcome, {session.user.name}&amp;lt;/span&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth-client.ts
import { createAuthClient } from &quot;better-auth/react&quot;;

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;createAuthClient()&lt;/code&gt; from &lt;code&gt;better-auth/react&lt;/code&gt; gives you a typed &lt;code&gt;useSession()&lt;/code&gt; hook that stays in sync with the server — if the session expires or the user signs out in another tab, it updates automatically. Auth.js has the equivalent &lt;code&gt;useSession()&lt;/code&gt; hook from &lt;code&gt;next-auth/react&lt;/code&gt;, wrapped in a &lt;code&gt;&amp;lt;SessionProvider&amp;gt;&lt;/code&gt; at the root of your app.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;feature-comparison&quot;&gt;Feature Comparison&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;Auth.js (v5) &lt;/th&gt;&lt;th&gt;Better Auth&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Status &lt;/td&gt;&lt;td&gt;Maintenance mode — security patches only &lt;/td&gt;&lt;td&gt;Actively developed, now backed by Vercel&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Requires a database &lt;/td&gt;&lt;td&gt;No (JWT sessions work standalone) &lt;/td&gt;&lt;td&gt;No for basic auth since v1.4 (stateless mode) — still required for most plugins&amp;lt;br/&amp;gt; (passkeys, 2FA, organizations)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Session model &lt;/td&gt;&lt;td&gt;JWT by default, database sessions optional &lt;/td&gt;&lt;td&gt;Database sessions by default, stateless signed-cookie sessions optional since v1.4&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OAuth providers &lt;/td&gt;&lt;td&gt;Large built-in provider ecosystem &lt;/td&gt;&lt;td&gt;Built in, growing list&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Passkeys (WebAuthn) &lt;/td&gt;&lt;td&gt;Not built in — experimental &lt;code&gt;Passkey&lt;/code&gt; provider&amp;lt;br/&amp;gt;exists but needs an adapter &amp;amp; is marked not&amp;lt;br/&amp;gt; production-ready &lt;/td&gt;&lt;td&gt;Built-in &lt;code&gt;passkey()&lt;/code&gt; plugin, ~10 lines of config&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two-factor auth &lt;/td&gt;&lt;td&gt;Build it yourself &lt;/td&gt;&lt;td&gt;Built-in plugin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Organizations / multi-tenancy &lt;/td&gt;&lt;td&gt;Build it yourself &lt;/td&gt;&lt;td&gt;Built-in plugin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Role-based access control &lt;/td&gt;&lt;td&gt;Build it yourself &lt;/td&gt;&lt;td&gt;Built-in plugin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Framework support &lt;/td&gt;&lt;td&gt;Next.js-first (also SvelteKit, Express via &lt;code&gt;@auth/core&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;Framework-agnostic — Next.js, Nuxt, SvelteKit, TanStack Start, Expo, plain Node&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Instant session revocation &lt;/td&gt;&lt;td&gt;Only with database sessions &lt;/td&gt;&lt;td&gt;Yes, by default&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Edge runtime friendly &lt;/td&gt;&lt;td&gt;Yes, with JWT sessions &lt;/td&gt;&lt;td&gt;Depends on adapter/runtime support&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;passkeys-the-feature-gap-that-matters-most&quot;&gt;Passkeys: The Feature Gap That Matters Most&lt;/h3&gt;
&lt;p&gt;If passkey sign-in is a requirement, this is where the two libraries genuinely diverge, not just in convenience.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Auth.js&lt;/strong&gt; ships an experimental &lt;code&gt;Passkey&lt;/code&gt; provider, but the docs are explicit that it's not recommended for production, and it requires switching from stateless JWT sessions to a full database adapter just to use it — a real architectural change, not a one-line addition.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Better Auth&lt;/strong&gt; treats passkeys as a first-class plugin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @better-auth/passkey
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth.ts
import { betterAuth } from &quot;better-auth&quot;;
import { passkey } from &quot;@better-auth/passkey&quot;;

export const auth = betterAuth({
  // ...your database and provider config
  plugins: [passkey()],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth-client.ts
import { createAuthClient } from &quot;better-auth/react&quot;;
import { passkeyClient } from &quot;@better-auth/passkey/client&quot;;

export const authClient = createAuthClient({
  plugins: [passkeyClient()],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; the &lt;code&gt;passkey()&lt;/code&gt; plugin (powered by SimpleWebAuthn internally) adds the database fields, registration flow, and authentication flow for you. After running the CLI migration, users can register and sign in with Face ID, Windows Hello, or a hardware key with no custom WebAuthn code on your end.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting to run &lt;code&gt;npx auth@latest migrate&lt;/code&gt; (or &lt;code&gt;generate&lt;/code&gt; + your ORM's own migration step) after adding a new plugin like &lt;code&gt;passkey()&lt;/code&gt; or &lt;code&gt;organization()&lt;/code&gt;. Each plugin adds its own tables/columns, and — since passkeys need somewhere to persist credentials — this is also exactly the case where Better Auth's stateless, no-database mode stops being an option. The plugin will fail at runtime, not build time, if the schema hasn't caught up.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;organizations-and-multi-tenant-apps&quot;&gt;Organizations and Multi-Tenant Apps&lt;/h3&gt;
&lt;p&gt;If you're building B2B SaaS — teams, workspaces, invitations, per-organization roles — this is the other place the gap is large.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Auth.js&lt;/strong&gt; has no organizations concept at all. You'd model teams yourself with your own database tables and write your own invitation, membership, and permission logic on top of Auth.js's session.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Better Auth&lt;/strong&gt; has this as a plugin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth.ts
import { betterAuth } from &quot;better-auth&quot;;
import { organization } from &quot;better-auth/plugins&quot;;

export const auth = betterAuth({
  // ...your database and provider config
  plugins: [organization()],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one plugin gives you organization creation, invitations, member roles, and the database schema for all of it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;security-don-t-rely-on-middleware-alone-with-either-library&quot;&gt;Security: Don't Rely on Middleware Alone, With Either Library&lt;/h3&gt;
&lt;p&gt;This applies regardless of which one you pick. Next.js had a documented middleware bypass (CVE-2025-29927) where a spoofed header could skip middleware checks entirely — including auth checks. It's patched now, but it's a good reminder that &lt;code&gt;proxy.ts&lt;/code&gt; (the renamed &lt;code&gt;middleware.ts&lt;/code&gt; in Next.js 16) should never be your only line of defense.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Inside a Server Action, regardless of which library you use
const session = await auth(); // Auth.js
// or
const session = await auth.api.getSession({ headers: await headers() }); // Better Auth

if (!session) {
  return { error: &quot;Unauthorized&quot; };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always re-check the session inside Server Actions and Route Handlers that touch real data. Treat &lt;code&gt;proxy.ts&lt;/code&gt; redirects as a UX nicety — a way to bounce an obviously-signed-out user before a page even renders — not your actual security boundary.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you're not familiar with how sessions and JWTs differ under the hood — including why database sessions support instant revocation and JWTs don't — see &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;Session vs JWT: Which Should You Use in Next.js 16?&lt;/a&gt; for the full breakdown.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pricing&quot;&gt;Pricing&lt;/h3&gt;
&lt;p&gt;Both libraries are free and open source under MIT. Neither charges per user, per seat, or per monthly active user — that's the core pitch of self-hosted auth versus a managed service like Clerk.&lt;/p&gt;
&lt;p&gt;The real cost difference is &lt;strong&gt;development time&lt;/strong&gt;, not license fees:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Auth.js &lt;/th&gt;&lt;th&gt;Better Auth&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;License cost &lt;/td&gt;&lt;td&gt;$0 &lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hosting cost &lt;/td&gt;&lt;td&gt;Only if using database sessions &lt;/td&gt;&lt;td&gt;Only if you add a database (needed for persistent users or most plugins)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dev time for passkeys / 2FA / orgs &lt;/td&gt;&lt;td&gt;You build it &lt;/td&gt;&lt;td&gt;Plugin, minutes of config — requires a database&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dev time for basic OAuth + sessions &lt;/td&gt;&lt;td&gt;Minutes &lt;/td&gt;&lt;td&gt;Minutes, stateless mode needs no DB setup at all&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If you need advanced features, Auth.js's &quot;free&quot; license comes with a real time cost — you're the one building 2FA or organizations from scratch. Better Auth trades a slightly longer initial setup for a lot less custom code later.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;migrating-from-auth-js-to-better-auth&quot;&gt;Migrating From Auth.js to Better Auth&lt;/h3&gt;
&lt;p&gt;If you already have a working Auth.js app and are considering a move, know this upfront: &lt;strong&gt;it's a parallel implementation, not a drop-in swap.&lt;/strong&gt; The session models are different enough (JWT vs. database sessions by default, different table shapes) that a big-bang cutover is riskier than it needs to be.&lt;/p&gt;
&lt;p&gt;A safer path:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add Better Auth alongside your existing Auth.js setup, on new routes only.&lt;/li&gt;
&lt;li&gt;Migrate your user table to Better Auth's schema using its CLI (&lt;code&gt;npx auth@latest generate&lt;/code&gt;, then a manual data migration script for existing users — Better Auth doesn't do this part for you).&lt;/li&gt;
&lt;li&gt;Move features one at a time — start with sign-in/sign-up, then migrate protected routes, then remove Auth.js once nothing depends on it.&lt;/li&gt;
&lt;li&gt;If you need zero-downtime migration, design an explicit transition strategy so existing Auth.js sessions can continue working while Better Auth is introduced.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Migration can require manual schema and identity mapping, so test the migration path before production cutover&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you have no existing NextAuth investment, skip this section entirely — just start with whichever library fits your feature needs from Step 1 above.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;decision-table&quot;&gt;Decision Table&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Your situation &lt;/th&gt;&lt;th&gt;Pick&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Maintaining an existing NextAuth v4/v5 app &lt;/td&gt;&lt;td&gt;Auth.js&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;New project, only need OAuth + basic sessions, no database yet &lt;/td&gt;&lt;td&gt;Auth.js (JWT mode) or Better Auth (stateless mode) — either works&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;New project, want passkeys or 2FA without building them yourself &lt;/td&gt;&lt;td&gt;Better Auth (needs a database for these plugins)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;B2B SaaS needing organizations, teams, or RBAC &lt;/td&gt;&lt;td&gt;Better Auth&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Need Edge-runtime session checks with zero database calls &lt;/td&gt;&lt;td&gt;Auth.js (JWT mode)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Want instant, guaranteed session revocation by default &lt;/td&gt;&lt;td&gt;Better Auth&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Building outside Next.js too (Nuxt, SvelteKit, Expo) &lt;/td&gt;&lt;td&gt;Better Auth&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Want the library actively gaining features month over month &lt;/td&gt;&lt;td&gt;Better Auth&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;is-auth-js-dead-&quot;&gt;Is Auth.js dead?&lt;/h4&gt;
&lt;p&gt;No — it's in maintenance mode, not abandoned. It still receives security patches, and existing apps using it don't need to migrate urgently. What's stopped is new feature development; that work moved to Better Auth after the Better Auth team took over Auth.js's maintenance.&lt;/p&gt;
&lt;h4 id=&quot;is-nextauth-the-same-thing-as-auth-js-&quot;&gt;Is NextAuth the same thing as Auth.js?&lt;/h4&gt;
&lt;p&gt;Yes. NextAuth renamed itself to Auth.js when it expanded beyond Next.js to other frameworks. The npm package is still installed as &lt;code&gt;next-auth&lt;/code&gt; for backwards compatibility — only the public project name changed.&lt;/p&gt;
&lt;h4 id=&quot;does-better-auth-work-with-the-next-js-app-router-&quot;&gt;Does Better Auth work with the Next.js App Router?&lt;/h4&gt;
&lt;p&gt;Yes, and it's first-class supported. Better Auth ships a &lt;code&gt;toNextJsHandler()&lt;/code&gt; helper specifically for wiring its core into an App Router catch-all route, and its React client works in both Server and Client Components with the patterns shown above.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-a-database-for-better-auth-&quot;&gt;Do I need a database for Better Auth?&lt;/h4&gt;
&lt;p&gt;Not necessarily anymore. As of Better Auth 1.4 (November 2025), you can omit the &lt;code&gt;database&lt;/code&gt; option entirely and run stateless, signed-cookie sessions — the same JWT-style trade-off as Auth.js. You'll still need a database adapter (Drizzle, Prisma, Kysely, and others are supported) the moment you want persistent user records or most plugins, since the docs are explicit that plugins like &lt;code&gt;passkey()&lt;/code&gt;, &lt;code&gt;organization()&lt;/code&gt;, and two-factor auth require one.&lt;/p&gt;
&lt;h4 id=&quot;has-the-better-auth-cli-command-changed-&quot;&gt;Has the Better Auth CLI command changed?&lt;/h4&gt;
&lt;p&gt;Yes. It used to be published under &lt;code&gt;@better-auth/cli&lt;/code&gt;; it's now the &lt;code&gt;auth&lt;/code&gt; package on npm, invoked as &lt;code&gt;npx auth@latest generate&lt;/code&gt; and &lt;code&gt;npx auth@latest migrate&lt;/code&gt; (plus newer commands like &lt;code&gt;info&lt;/code&gt; and &lt;code&gt;create-admin&lt;/code&gt;). If you see &lt;code&gt;@better-auth/cli&lt;/code&gt; in an older tutorial, it's the same tool under its previous package name — the underlying &lt;code&gt;generate&lt;/code&gt;/&lt;code&gt;migrate&lt;/code&gt; behavior hasn't changed.&lt;/p&gt;
&lt;h4 id=&quot;will-vercel-s-acquisition-of-better-auth-change-the-pricing-or-license-&quot;&gt;Will Vercel's acquisition of Better Auth change the pricing or license?&lt;/h4&gt;
&lt;p&gt;As of the acquisition, Vercel confirmed Better Auth stays free and MIT-licensed, keeps its name, and the original team continues leading development with the same open contribution model. There's no indication of a license change.&lt;/p&gt;
&lt;h4 id=&quot;can-i-use-better-auth-outside-of-next-js-&quot;&gt;Can I use Better Auth outside of Next.js?&lt;/h4&gt;
&lt;p&gt;Yes — that's one of its core differences from Auth.js. Better Auth is framework-agnostic and has documented support for Nuxt, SvelteKit, TanStack Start, Express, Hono, and even Expo for React Native, all sharing the same core auth logic.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Auth.js and Better Auth solve the same core problem — proving who a user is and keeping that state in sync across your app — but they're at very different points in their lifecycle right now. Auth.js is stable, well-documented, and frozen in scope. Better Auth is younger, can now run database-free for basic auth just like Auth.js, and is where the ecosystem's energy (and now Vercel's resources) are actually going — the database is only a hard requirement once you reach for its plugin ecosystem.&lt;/p&gt;
&lt;p&gt;For a brand-new Next.js project in 2026 with no legacy NextAuth code to maintain, Better Auth is the safer long-term bet, especially if passkeys, 2FA, or organizations are anywhere on your roadmap. If you're maintaining something that already runs on Auth.js and it's not causing problems, there's no urgency to rip it out — keep shipping, and revisit the decision only when you hit a feature Auth.js genuinely can't give you.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is pairing whichever library you pick with &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Role-Based Access Control&lt;/a&gt; on top of the session, or reading &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;Session vs JWT in Next.js 16&lt;/a&gt; to decide which session model actually fits your app before you commit to one.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;How to Add NextAuth (Auth.js v5) in Next.js 16&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;Add Clerk Authentication to Next.js 16 (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth: Which One Should You Choose in 2026?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — pair this guide's application-level RBAC with database-level Row Level Security. Generates owner-only, team, and admin-override policies with the &lt;code&gt;auth.uid()&lt;/code&gt; performance wrapper built in.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod Schema Generator&lt;/a&gt; — generate a validated schema for your role/permission payloads straight from a JSON sample or TypeScript interface, including a ready-to-use Server Action.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/vercel-cron-generator&quot;&gt;Vercel Cron Expression Generator&lt;/a&gt; - generate Vercel-compatible cron expressions with a visual schedule builder, live validation, &amp;amp; a preview of the next 5 execution times in UTC.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25814/auth-js-vs-better-auth-in-2026-which-should-you-use</guid>
<pubDate>Sun, 30 Aug 2026 11:23:59 +0000</pubDate>
</item>
<item>
<title>Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)</title>
<link>https://coderlegion.com/24809/role-based-access-control-rbac-in-next-js-16-2026-beginners-guide</link>
<description>&lt;p&gt;Logging a user in is the easy part. The hard part shows up right after — when your &quot;admin&quot; and your &quot;customer&quot; both hit &lt;code&gt;/dashboard&lt;/code&gt; and somehow need to see completely different things. Or worse, when a regular user figures out they can just type &lt;code&gt;/admin&lt;/code&gt; into the URL bar and… get in, because nothing was actually checking who they were.&lt;/p&gt;
&lt;p&gt;That's the gap &lt;strong&gt;role-based authorization&lt;/strong&gt; (RBAC — Role-Based Access Control) fills. It's not about &lt;em&gt;whether&lt;/em&gt; someone is logged in anymore — it's about &lt;em&gt;what they're allowed to do&lt;/em&gt; once they are. An admin can delete users. An editor can publish posts. A viewer can only look. Same login system, three completely different sets of permissions.&lt;/p&gt;
&lt;p&gt;In this guide, you'll build a complete role-based authorization system in Next.js 16 — from storing roles, to protecting routes with &lt;code&gt;proxy.ts&lt;/code&gt;, to locking down Server Actions and API routes so a sneaky &lt;code&gt;curl&lt;/code&gt; request can't bypass your UI checks. By the end, you'll have a reusable pattern you can drop into any SaaS, dashboard, or internal tool.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Authentication just answers &quot;who are you?&quot; Authorization answers &quot;what are you allowed to do?&quot; — and that second question has to be checked in &lt;strong&gt;every place&lt;/strong&gt; data or actions are exposed, not just once at the login screen.&lt;/p&gt;
&lt;p&gt;The mistake almost everyone makes early on is checking roles only in the UI — hiding an &quot;Edit&quot; button if &lt;code&gt;role !== &quot;admin&quot;&lt;/code&gt;. That stops confused users, but it does nothing to stop a determined one, since a hidden button doesn't stop someone from calling the underlying Server Action or API route directly. Real role-based auth needs checks in at least three layers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Route level&lt;/strong&gt; — don't even let the page load for the wrong role&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UI level&lt;/strong&gt; — hide actions the user can't perform (good UX, not security)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server level&lt;/strong&gt; — re-verify inside every Server Action, Route Handler, and database query (the actual security boundary)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We'll build all three.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building&quot;&gt;What We're Building&lt;/h3&gt;
&lt;p&gt;A small app with three roles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;admin&lt;/strong&gt; — full access, can manage users&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;editor&lt;/strong&gt; — can create and edit posts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;viewer&lt;/strong&gt; — read-only access&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You'll end up with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A typed &lt;code&gt;Role&lt;/code&gt; system shared across the whole app&lt;/li&gt;
&lt;li&gt;Role storage using Auth.js v5's JWT session (works the same pattern with a database session)&lt;/li&gt;
&lt;li&gt;Route protection in &lt;code&gt;proxy.ts&lt;/code&gt; based on role, not just login state&lt;/li&gt;
&lt;li&gt;A reusable &lt;code&gt;requirePermission()&lt;/code&gt; helper for Server Actions and Route Handlers&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; component for conditionally showing UI based on permissions&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;                 ┌──────────────┐
                 │    Role      │
                 │ admin/editor │
                 │    viewer    │
                 └──────┬───────┘
                        ↓
                 ┌──────────────┐
                 │ Permissions  │
                 └──────┬───────┘
                        ↓
              ┌─────────┴─────────┐
              ↓                   ↓
      requirePermission()       &amp;lt;Can&amp;gt;
              ↓                   ↓
       Security boundary      UI visibility
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;tech-stack&quot;&gt;Tech Stack&lt;/h4&gt;
&lt;p&gt;This guide uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the small UI examples&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth.js v5&lt;/strong&gt; (&lt;code&gt;next-auth@beta&lt;/code&gt;) for authentication — the same library covered in &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Don't have Auth.js set up yet? Read &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;How to Add Auth.js (NextAuth v5) Authentication in Next.js 16&lt;/a&gt; first — this guide picks up right where that one leaves off. If you're using &lt;strong&gt;Clerk&lt;/strong&gt; instead, skip to the &lt;a rel=&quot;nofollow&quot; href=&quot;#doing-this-with-clerk-instead&quot;&gt;Clerk section&lt;/a&gt; near the end — the concepts are identical, just the plumbing changes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-define-your-roles-in-one-place&quot;&gt;Step 1: Define Your Roles in One Place&lt;/h3&gt;
&lt;p&gt;Before writing any auth code, decide what roles exist and what each one can actually do. Hardcoding role strings (&quot;admin&quot;, &quot;editor&quot;...) all over your codebase is exactly how typos slip in and silently break a permission check.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth/roles.ts
export const ROLES = [&quot;admin&quot;, &quot;editor&quot;, &quot;viewer&quot;] as const;

export type Role = (typeof ROLES)[number];

// What each role is allowed to do — the single source of truth
export const PERMISSIONS = {
  admin: [&quot;manage_users&quot;, &quot;create_post&quot;, &quot;edit_post&quot;, &quot;delete_post&quot;, &quot;view_post&quot;],
  editor: [&quot;create_post&quot;, &quot;edit_post&quot;, &quot;view_post&quot;],
  viewer: [&quot;view_post&quot;],
} as const satisfies Record&amp;lt;Role, readonly string[]&amp;gt;;

export type Permission = (typeof PERMISSIONS)[Role][number];

export function hasPermission(role: Role, permission: Permission): boolean {
  return (PERMISSIONS[role] as readonly string[]).includes(permission);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;ROLES&lt;/code&gt; is a fixed list — TypeScript's &lt;code&gt;as const&lt;/code&gt; makes &lt;code&gt;Role&lt;/code&gt; a union type (&lt;code&gt;&quot;admin&quot; | &quot;editor&quot; | &quot;viewer&quot;&lt;/code&gt;) instead of a loose &lt;code&gt;string&lt;/code&gt;, so a typo like &lt;code&gt;&quot;admn&quot;&lt;/code&gt; fails to compile instead of silently failing at runtime. &lt;code&gt;PERMISSIONS&lt;/code&gt; maps each role to what it's allowed to do, and &lt;code&gt;hasPermission()&lt;/code&gt; is the one function every check in this guide will eventually call.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Notice we're checking &lt;strong&gt;permissions&lt;/strong&gt; (&lt;code&gt;&quot;create_post&quot;&lt;/code&gt;), not roles (&lt;code&gt;&quot;editor&quot;&lt;/code&gt;), everywhere except this one file. If you ever add a new role or change what editors can do, you update this file once — nothing else in your app needs to change.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-store-the-role-on-the-user&quot;&gt;Step 2: Store the Role on the User&lt;/h3&gt;
&lt;p&gt;Your role has to live somewhere Auth.js can read it during login. In a real app, this is a &lt;code&gt;role&lt;/code&gt; column on your &lt;code&gt;users&lt;/code&gt; table. For this guide, here's the shape assuming you're using a database adapter (Prisma, Drizzle, or similar):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Minimal schema addition
alter table users
add column role text not null default 'viewer'
check (role in ('admin', 'editor', 'viewer'));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;New users default to the lowest-privilege role — &lt;code&gt;viewer&lt;/code&gt; — which is a good default for any RBAC system. Never default a new signup to &lt;code&gt;admin&lt;/code&gt;. &lt;/p&gt;
&lt;p&gt;TypeScript protects application code at compile time; the database constraint protects persisted data at runtime.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Letting the client send its own role during signup (&lt;code&gt;formData.get(&quot;role&quot;)&lt;/code&gt;). Always assign the default role server-side and require an existing admin to promote anyone — otherwise anyone can sign up and just declare themselves an admin.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-put-the-role-on-the-session&quot;&gt;Step 3: Put the Role on the Session&lt;/h3&gt;
&lt;p&gt;This is the step most tutorials skip, and it's the one that actually makes RBAC work. By default, Auth.js's session only contains the basics (name, email, image) — you have to explicitly add &lt;code&gt;role&lt;/code&gt; to it using two callbacks: &lt;code&gt;jwt&lt;/code&gt; and &lt;code&gt;session&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth.ts
import NextAuth from &quot;next-auth&quot;;
import GitHub from &quot;next-auth/providers/github&quot;;
import type { Role } from &quot;./auth/roles&quot;;

declare module &quot;next-auth&quot; {
  interface User {
    id: string;
    role: Role;
  }
  interface Session {
    user: {
      id: string;
      role: Role;
    } &amp;amp; DefaultSession[&quot;user&quot;];
  }
}

declare module &quot;@auth/core/jwt&quot; {
  interface JWT {
    role: Role;
    id: string;
  }
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [GitHub],
  callbacks: {
    async jwt({ token, user }) {
      // Runs on sign-in, and on every subsequent request
      if (user) {
        // `user` is only defined right after login — look up the role from your DB here
        token.id = user.id!;
        token.role = user.role ?? &quot;viewer&quot;;
      }
      return token;
    },
    async session({ session, token }) {
      // Copies data from the JWT token into the session object your app actually reads
      session.user.id = token.id;
      session.user.role = token.role;
      return session;
    },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;jwt()&lt;/code&gt; can run whenever the JWT session is created or updated, but the &lt;code&gt;user&lt;/code&gt; object is available during the initial sign-in flow. That's where you copy the user's role into &lt;code&gt;token.role&lt;/code&gt;. On later requests, the role is read from the token rather than fetched again. &lt;code&gt;session()&lt;/code&gt; then runs on every &lt;code&gt;auth()&lt;/code&gt; call and copies fields from that token onto the &lt;code&gt;session&lt;/code&gt; object your pages and Server Actions actually read. The &lt;code&gt;declare module&lt;/code&gt; blocks aren't runtime code — they extend Auth.js's built-in TypeScript types so &lt;code&gt;session.user.role&lt;/code&gt; autocompletes instead of throwing a type error.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Trying to read the role directly off &lt;code&gt;session.user&lt;/code&gt; without adding it in these two callbacks first. Auth.js won't magically know about a custom &lt;code&gt;role&lt;/code&gt; column — you have to wire it through &lt;code&gt;jwt()&lt;/code&gt; → &lt;code&gt;session()&lt;/code&gt; explicitly, every time.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If a user's role changes (e.g. an admin promotes them), the JWT won't reflect that until they log in again, since &lt;code&gt;jwt()&lt;/code&gt; only re-reads &lt;code&gt;user&lt;/code&gt; on fresh sign-in. For roles that need to update instantly, either switch to database sessions (&lt;code&gt;strategy: &quot;database&quot;&lt;/code&gt;) or add a &lt;code&gt;trigger === &quot;update&quot;&lt;/code&gt; branch that re-fetches the role on demand.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-protect-routes-by-role-in-proxy-ts&quot;&gt;Step 4: Protect Routes by Role in &lt;code&gt;proxy.ts&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;In Next.js 16, &lt;code&gt;middleware.ts&lt;/code&gt; was renamed to &lt;code&gt;proxy.ts&lt;/code&gt; — same file conventions, same idea: it runs before a request reaches a page, making it useful for a fast, optimistic route-level redirect before the page renders.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts
import { NextResponse } from &quot;next/server&quot;;
import { auth } from &quot;@/lib/auth&quot;;
import type { Role } from &quot;@/lib/auth/roles&quot;;

// Map route prefixes to the roles allowed to access them
const ROUTE_ROLES: { prefix: string; roles: Role[] }[] = [
  { prefix: &quot;/admin&quot;, roles: [&quot;admin&quot;] },
  { prefix: &quot;/editor&quot;, roles: [&quot;admin&quot;, &quot;editor&quot;] },
  { prefix: &quot;/dashboard&quot;, roles: [&quot;admin&quot;, &quot;editor&quot;, &quot;viewer&quot;] },
];

export default auth((req) =&amp;gt; {
  const { nextUrl } = req;
  const role = req.auth?.user?.role;

  const matched = ROUTE_ROLES.find((r) =&amp;gt; nextUrl.pathname === r.prefix || nextUrl.pathname.startsWith(`${r.prefix}/`));
  const isAuthPage = nextUrl.pathname === &quot;/login&quot;;
  const isLoggedIn = !!req.auth;

  if (isAuthPage &amp;amp;&amp;amp; isLoggedIn) {
      // Redirect authenticated users to the dashboard
      return NextResponse.redirect(new URL(&quot;/dashboard&quot;, nextUrl))
  }

  if (!matched) {
    return NextResponse.next(); // route isn't role-protected, let it through
  }

  if (!isLoggedIn) {
    // Not logged in at all — send to login
    return NextResponse.redirect(new URL(&quot;/login&quot;, nextUrl));
  }

  if (!role || !matched.roles.includes(role)) {
    // Logged in, but wrong role — send to a friendly &quot;not allowed&quot; page, not login
    return NextResponse.redirect(new URL(&quot;/unauthorized&quot;, nextUrl));
  }

  return NextResponse.next();
});

export const config = {
  matcher: [&quot;/((?!api|_next/static|_next/image|favicon.ico).*)&quot;],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;ROUTE_ROLES&lt;/code&gt; is a lookup table — no scattered &lt;code&gt;if&lt;/code&gt; statements per route, just prefixes mapped to the roles allowed in. The proxy checks the incoming path against that table, and only bothers checking &lt;code&gt;req.auth&lt;/code&gt; at all if the route is actually protected. Notice the two different redirects: no session → &lt;code&gt;/login&lt;/code&gt; (they need to authenticate), wrong role → &lt;code&gt;/unauthorized&lt;/code&gt; (they're authenticated, just not allowed here) — conflating these two cases is a common source of confusing UX.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting the &lt;code&gt;matcher&lt;/code&gt; config, or matching too broadly. Running the proxy on every static asset request adds unnecessary overhead and can cause odd caching behavior — always exclude &lt;code&gt;_next/static&lt;/code&gt;, &lt;code&gt;_next/image&lt;/code&gt;, and favicon paths.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-build-a-requirepermission-helper-for-the-real-security-boundary&quot;&gt;Step 5: Build a &lt;code&gt;requirePermission()&lt;/code&gt; Helper for the Real Security Boundary&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;proxy.ts&lt;/code&gt; stops the page from loading, but it does &lt;strong&gt;not&lt;/strong&gt; protect Server Actions or Route Handlers called directly — those need their own check. This is the layer that actually matters for security, since &lt;code&gt;proxy.ts&lt;/code&gt; is a UX nicety, not a guarantee.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth/require-role.ts
import { auth } from &quot;@/lib/auth&quot;;
import type { Permission } from &quot;./roles&quot;;
import { hasPermission } from &quot;./roles&quot;;

export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}

export async function requirePermission(permission: Permission) {
  const session = await auth();

  if (!session?.user) {
    throw new UnauthorizedError(&quot;You must be signed in.&quot;);
  }

  if (!hasPermission(session.user.role, permission)) {
    throw new ForbiddenError(&quot;You don't have permission to do that.&quot;);
  }

  return session.user; // callers get a typed, guaranteed-authorized user back
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;requirePermission()&lt;/code&gt; checks permissions (&lt;code&gt;&quot;delete_post&quot;&lt;/code&gt;), not roles directly — this keeps every call site decoupled from &lt;em&gt;which&lt;/em&gt; roles can do what, so changing that mapping later only touches &lt;code&gt;roles.ts&lt;/code&gt;. Two distinct errors (&lt;code&gt;Unauthorized&lt;/code&gt; vs &lt;code&gt;Forbidden&lt;/code&gt;) let callers respond differently — a 401 (not logged in) is a different problem than a 403 (logged in, not allowed).&lt;/p&gt;
&lt;h4 id=&quot;using-it-in-a-server-action&quot;&gt;Using It in a Server Action&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/posts.ts
&quot;use server&quot;;

import { requirePermission, ForbiddenError, UnauthorizedError } from &quot;@/lib/auth/require-role&quot;;
import { revalidatePath } from &quot;next/cache&quot;;

export async function deletePost(postId: string) {
  try {
    await requirePermission(&quot;delete_post&quot;);
  } catch (err) {
    if (err instanceof UnauthorizedError) return { error: &quot;Please sign in.&quot; };
    if (err instanceof ForbiddenError) return { error: &quot;You can't delete posts.&quot; };
    throw err;
  }

  // await db.post.delete({ where: { id: postId } });
  revalidatePath(&quot;/dashboard&quot;);
  return { success: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Only checking permissions in &lt;code&gt;proxy.ts&lt;/code&gt; and assuming Server Actions are automatically covered. They aren't — a Server Action is a real network endpoint under the hood, callable directly, and needs its own &lt;code&gt;requirePermission()&lt;/code&gt; call every single time it touches something sensitive.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;using-it-in-a-route-handler&quot;&gt;Using It in a Route Handler&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/users/[id]/route.ts
import { NextResponse } from &quot;next/server&quot;;
import { requirePermission, ForbiddenError, UnauthorizedError } from &quot;@/lib/auth/require-role&quot;;

export async function DELETE(req: Request, { params }: { params: Promise&amp;lt;{ id: string }&amp;gt; }) {
  try {
    await requirePermission(&quot;manage_users&quot;);
  } catch (err) {
    if (err instanceof UnauthorizedError) {
      return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
    }
    if (err instanceof ForbiddenError) {
      return NextResponse.json({ error: &quot;Forbidden&quot; }, { status: 403 });
    }
    throw err;
  }

  const { id } = await params;
  // await db.user.delete({ where: { id } });
  return NextResponse.json({ success: true });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same pattern, correct HTTP status codes this time — &lt;code&gt;401&lt;/code&gt; for &quot;log in first,&quot; &lt;code&gt;403&lt;/code&gt; for &quot;you're logged in, but no.&quot;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-conditionally-show-ui-with-a-lt-can-gt-component&quot;&gt;Step 6: Conditionally Show UI with a &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; Component&lt;/h3&gt;
&lt;p&gt;Server-side checks are the real security boundary, but users still need a UI that doesn't dangle buttons they can't actually use. A small &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; component keeps this readable instead of &lt;code&gt;{role === &quot;admin&quot; &amp;amp;&amp;amp; ...}&lt;/code&gt; scattered through every page.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/can.tsx
import type { Permission } from &quot;@/lib/auth/roles&quot;;
import { hasPermission } from &quot;@/lib/auth/roles&quot;;
import { auth } from &quot;@/lib/auth&quot;;

interface CanProps {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export async function Can({ permission, children, fallback = null }: CanProps) {
  const session = await auth();

  if (!session?.user || !hasPermission(session.user.role, permission)) {
    return &amp;lt;&amp;gt;{fallback}&amp;lt;/&amp;gt;;
  }

  return &amp;lt;&amp;gt;{children}&amp;lt;/&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { Can } from &quot;@/components/can&quot;;
import Link from 'next/link';

export default function DashboardPage() {
  return (
    &amp;lt;div className=&quot;space-y-4&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-2xl font-semibold&quot;&amp;gt;Dashboard&amp;lt;/h1&amp;gt;

      &amp;lt;Can permission=&quot;create_post&quot;&amp;gt;
        &amp;lt;button className=&quot;rounded-lg bg-primary px-4 py-2 text-white&quot;&amp;gt;
          New Post
        &amp;lt;/button&amp;gt;
      &amp;lt;/Can&amp;gt;

      &amp;lt;Can permission=&quot;manage_users&quot; fallback={&amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;Admins only.&amp;lt;/p&amp;gt;}&amp;gt;
        &amp;lt;Link href=&quot;/admin/users&quot; className=&quot;underline&quot;&amp;gt;Manage Users&amp;lt;/Link&amp;gt;
      &amp;lt;/Can&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;Can&lt;/code&gt; is an async Server Component — it calls &lt;code&gt;auth()&lt;/code&gt; directly, no client-side loading flicker, no &lt;code&gt;useEffect&lt;/code&gt;. Wrap anything role-gated in it, optionally supply a &lt;code&gt;fallback&lt;/code&gt; for what non-permitted users see instead (or leave it out to render nothing at all).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Because &lt;code&gt;Can&lt;/code&gt; reads the current session on the server, use it in parts of the tree where request-time authentication data is expected.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; This component controls &lt;em&gt;visibility&lt;/em&gt;, not &lt;em&gt;security&lt;/em&gt;. Someone could still call the underlying Server Action with dev tools open — that's exactly why Step 5's &lt;code&gt;requirePermission()&lt;/code&gt; exists as the real gate. &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; is UX, &lt;code&gt;requirePermission()&lt;/code&gt; is the lock.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-filter-data-at-the-query-level-too&quot;&gt;Step 7: Filter Data at the Query Level Too&lt;/h3&gt;
&lt;p&gt;There's a fourth layer worth calling out separately: what data a query even returns. A &lt;code&gt;viewer&lt;/code&gt; shouldn't just be blocked from an edit &lt;em&gt;button&lt;/em&gt; — if your query returns draft posts by default, make sure a viewer's query never fetches drafts in the first place, rather than fetching everything and hiding it in the UI.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/data/posts.ts
import { auth } from &quot;@/lib/auth&quot;;
import { UnauthorizedError } from &quot;../auth/require-role&quot;;

export async function getVisiblePosts() {
  const session = await auth();
  const role = session?.user?.role;

  if (!session?.user) {
    throw new UnauthorizedError(&quot;You must be signed in.&quot;);
  }

  if (role === &quot;admin&quot; || role === &quot;editor&quot;) {
    // return await db.post.findMany(); // everything, including drafts
  }

  // return await db.post.findMany({ where: { status: &quot;published&quot; } }); // viewers only see published
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This matters because a UI-only check can miss cases like server-rendered pages, RSS feeds, or an API response that isn't wired through the component you added &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; to.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-1&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;what-s-the-difference-between-authentication-and-authorization-&quot;&gt;What's the difference between authentication and authorization?&lt;/h4&gt;
&lt;p&gt;Authentication confirms &lt;em&gt;who&lt;/em&gt; someone is (logging in). Authorization decides &lt;em&gt;what&lt;/em&gt; they're allowed to do once they're recognized (roles and permissions). You need both — Auth.js/Clerk handle authentication, and everything in this guide is the authorization layer on top.&lt;/p&gt;
&lt;h4 id=&quot;should-i-store-roles-as-a-string-or-a-separate-roles-table-&quot;&gt;Should I store roles as a string or a separate roles table?&lt;/h4&gt;
&lt;p&gt;A single &lt;code&gt;role&lt;/code&gt; string column (like this guide uses) is fine for simple apps with a handful of fixed roles. If you need multiple roles per user, per-organization roles, or roles that admins can create dynamically, move to a proper &lt;code&gt;roles&lt;/code&gt; and &lt;code&gt;user_roles&lt;/code&gt; join table instead — the &lt;code&gt;hasPermission()&lt;/code&gt; pattern still works the same way on top of either.&lt;/p&gt;
&lt;h4 id=&quot;is-checking-roles-in-proxy-ts-enough-security-by-itself-&quot;&gt;Is checking roles in &lt;code&gt;proxy.ts&lt;/code&gt; enough security by itself?&lt;/h4&gt;
&lt;p&gt;No. Treat &lt;code&gt;proxy.ts&lt;/code&gt; as a fast UX redirect, not your security boundary — it can be bypassed by hitting a Server Action or API route directly. Always re-check permissions inside the Server Action or Route Handler itself, as shown in Step 5.&lt;/p&gt;
&lt;h4 id=&quot;how-do-i-handle-a-role-change-without-forcing-the-user-to-log-out-&quot;&gt;How do I handle a role change without forcing the user to log out?&lt;/h4&gt;
&lt;p&gt;With JWT sessions, the user value isn't available on every JWT callback invocation, so simply changing the role in your database doesn't automatically replace the role already stored in an existing token. Either switch to &lt;code&gt;strategy: &quot;database&quot;&lt;/code&gt; sessions (Auth.js re-reads the DB on every request), or add an &lt;code&gt;update()&lt;/code&gt; trigger that refreshes the token when an admin changes someone's role.&lt;/p&gt;
&lt;h4 id=&quot;can-i-have-more-granular-permissions-than-just-three-roles-&quot;&gt;Can I have more granular permissions than just three roles?&lt;/h4&gt;
&lt;p&gt;Yes — that's exactly why Step 1 separates &lt;code&gt;PERMISSIONS&lt;/code&gt; from &lt;code&gt;ROLES&lt;/code&gt;. Add a new permission string to whichever roles should have it, and every &lt;code&gt;requirePermission()&lt;/code&gt; call automatically respects the new mapping without touching the rest of your codebase.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;doing-this-with-clerk-instead&quot;&gt;Doing This with Clerk Instead&lt;/h2&gt;
&lt;p&gt;If you're using &lt;a rel=&quot;nofollow&quot; href=&quot;https://clerk.com/&quot;&gt;Clerk&lt;/a&gt; instead of Auth.js, the RBAC architecture stays the same. Only the way you retrieve the user's role changes.&lt;/p&gt;
&lt;p&gt;For a simple app, store the role in Clerk's &lt;code&gt;publicMetadata&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;{
  role: &quot;editor&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set it server-side or through the Clerk Dashboard. &lt;strong&gt;Never let users submit their own role during signup.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Expose the metadata through the Clerk session token. In &lt;strong&gt;Clerk Dashboard → Sessions → Customize session token&lt;/strong&gt;, add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;metadata&quot;: &quot;{{user.public_metadata}}&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then read the role with &lt;code&gt;auth()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { auth } from &quot;@clerk/nextjs/server&quot;;
import type { Role } from &quot;@/lib/auth/roles&quot;;

const { isAuthenticated, sessionClaims } = await auth();

if (!isAuthenticated) {
  // Not signed in
}

const role = sessionClaims?.metadata?.role as Role | undefined;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From here, the same &lt;code&gt;hasPermission()&lt;/code&gt;, &lt;code&gt;requirePermission()&lt;/code&gt;, and &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; pattern from this guide still applies. Your authorization layer doesn't need to care whether the role came from Auth.js or Clerk.&lt;/p&gt;
&lt;p&gt;Role changes may take a short time to appear in an existing session token, so don't assume a metadata update is immediately reflected in &lt;code&gt;sessionClaims&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you're building a multi-tenant SaaS with teams or organizations, consider Clerk's built-in &lt;strong&gt;Organizations, Roles, and Permissions&lt;/strong&gt; instead of storing organization roles in &lt;code&gt;publicMetadata&lt;/code&gt;. (&lt;a rel=&quot;nofollow&quot; href=&quot;https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions&quot;&gt;Clerk Roles &amp;amp; Permissions&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The important rule remains the same: &lt;strong&gt;UI checks improve UX; server-side permission checks provide security.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Role-based authorization isn't one big feature — it's four small, boring checks layered together: a protected route in &lt;code&gt;proxy.ts&lt;/code&gt;, a re-check inside every Server Action and Route Handler, a &lt;code&gt;&amp;lt;Can&amp;gt;&lt;/code&gt; wrapper for the UI, and a query that filters data before it ever leaves the server. Skip any one layer and you've got a gap; stack all four and a &quot;hidden&quot; admin button stops being a real vulnerability.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is adding an admin UI for promoting/demoting users, or layering row-level security on top if you're using &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/&quot;&gt;Supabase&lt;/a&gt; alongside this pattern for defense in depth at the database layer too.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/Role-base-Authorization-Nextjs-setup&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;continue-learning-1&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;How to Add Clerk Authentication in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools-1&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — pair this guide's application-level RBAC with database-level Row Level Security. Generates owner-only, team, and admin-override policies with the &lt;code&gt;auth.uid()&lt;/code&gt; performance wrapper built in.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod Schema Generator&lt;/a&gt; — generate a validated schema for your role/permission payloads straight from a JSON sample or TypeScript interface, including a ready-to-use Server Action.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24809/role-based-access-control-rbac-in-next-js-16-2026-beginners-guide</guid>
<pubDate>Tue, 18 Aug 2026 03:49:46 +0000</pubDate>
</item>
<item>
<title>How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)</title>
<link>https://coderlegion.com/24071/how-to-add-auth-js-nextauth-v5-authentication-in-next-js-16-2026</link>
<description>&lt;p&gt;Every real app eventually needs to answer one question: &lt;em&gt;who is this user?&lt;/em&gt; The moment you add a dashboard, a settings page, or anything that shouldn't be public, you need authentication — and rolling your own login system from scratch (password hashing, sessions, CSRF protection, OAuth flows) is a rabbit hole most developers don't have time for.&lt;/p&gt;
&lt;p&gt;That's exactly what &lt;strong&gt;Auth.js&lt;/strong&gt; (the project formerly known as NextAuth) solves. It's free, open source, and gives you production-ready authentication — email/password, GitHub, Google, and dozens of other providers — without owning any of that security-critical code yourself.&lt;/p&gt;
&lt;p&gt;In this guide, you'll set up Auth.js v5 in a Next.js 16 App Router project from scratch: installing it, configuring a provider, protecting routes with &lt;code&gt;proxy.ts&lt;/code&gt;, and reading the logged-in user inside Server Components. By the end, you'll have a working &quot;Sign in with GitHub&quot; flow you can extend to any provider you want.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-you-ll-learn&quot;&gt;What you'll learn&lt;/h3&gt;
&lt;p&gt;By the end of this guide you'll know how to:&lt;/p&gt;
&lt;p&gt;✓ Install Auth.js v5&lt;br&gt;
✓ Configure GitHub OAuth&lt;br&gt;
✓ Protect routes&lt;br&gt;
✓ Read sessions&lt;br&gt;
✓ Use auth() inside Server Components&lt;br&gt;
✓ Secure Server Actions&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-auth-js-exactly-&quot;&gt;What Is Auth.js, Exactly?&lt;/h3&gt;
&lt;p&gt;Auth.js is an authentication library built specifically for full-stack JavaScript frameworks. If you've read older tutorials, you've probably seen it called &lt;strong&gt;NextAuth&lt;/strong&gt; — that's the same project. It was renamed to Auth.js when the team expanded support beyond Next.js to other frameworks like SvelteKit and Express, while the npm package name stayed &lt;code&gt;next-auth&lt;/code&gt; for backwards compatibility.&lt;/p&gt;
&lt;p&gt;Think of Auth.js as a &lt;strong&gt;middleman&lt;/strong&gt; between your app and the identity providers (GitHub, Google, your own database, etc.). Instead of you writing the OAuth handshake, generating secure session cookies, and validating tokens by hand, Auth.js handles all of it — you just tell it &lt;em&gt;which&lt;/em&gt; providers to use.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you've seen &lt;code&gt;getServerSession()&lt;/code&gt; in an older tutorial, that's the Auth.js v4 API. In v5 (what this guide uses), everything is exported from a single &lt;code&gt;auth()&lt;/code&gt; function instead — much simpler.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building-1&quot;&gt;What We're Building&lt;/h3&gt;
&lt;p&gt;By the end of this guide, you'll have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Auth.js v5 installed and configured in a Next.js 16 App Router app&lt;/li&gt;
&lt;li&gt;A working &lt;strong&gt;GitHub OAuth&lt;/strong&gt; sign-in flow (the pattern is the same for Google, Discord, etc.)&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;proxy.ts&lt;/code&gt; file that protects private routes automatically&lt;/li&gt;
&lt;li&gt;A Navbar that shows &quot;Sign in&quot; or the user's avatar depending on auth state&lt;/li&gt;
&lt;li&gt;Access to the logged-in user inside Server Components using &lt;code&gt;auth()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h4&gt;
&lt;p&gt;Before we start, make sure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; (strict mode)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for styling&lt;/li&gt;
&lt;li&gt;Basic familiarity with React and the App Router&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your project doesn't have these yet, scaffold one first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-next-app@latest my-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For this guide, we're using the &lt;code&gt;src/&lt;/code&gt; directory layout (choose &lt;strong&gt;Yes&lt;/strong&gt; when the CLI asks). Everything works the same without it — just drop the &lt;code&gt;src/&lt;/code&gt; prefix from the file paths below.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;folder-structure&quot;&gt;Folder Structure&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;src/
├── app/
│   └── api/
│       └── auth/
│           └── [...nextauth]/
│               └── route.ts
├── actions/
│   └── create-post.ts
├── components/
│   └── auth-buttons.tsx
├── lib/
│   └── auth.ts
├── proxy.ts
.env.local
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-does-auth-js-work-&quot;&gt;How does Auth.js work?&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;User clicks &quot;Sign in&quot;
        ↓
Redirect to GitHub
        ↓
User approves
        ↓
GitHub redirects back
        ↓
Auth.js validates
        ↓
Session cookie created
        ↓
auth() can now read the user
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-install-auth-js&quot;&gt;Step 1: Install Auth.js&lt;/h3&gt;
&lt;p&gt;Auth.js v5 is still installed under its old package name, &lt;code&gt;next-auth&lt;/code&gt;, using the &lt;code&gt;beta&lt;/code&gt; tag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install next-auth@beta
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Installing &lt;code&gt;next-auth&lt;/code&gt; without &lt;code&gt;@beta&lt;/code&gt; gives you the old v4 API, which uses a completely different setup (&lt;code&gt;[...nextauth].ts&lt;/code&gt; pages, &lt;code&gt;authOptions&lt;/code&gt;, &lt;code&gt;getServerSession()&lt;/code&gt;). This guide is written entirely for v5.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-create-a-github-oauth-app&quot;&gt;Step 2: Create a GitHub OAuth App&lt;/h3&gt;
&lt;p&gt;We're using GitHub as our sign-in provider in this guide. In one sentence: &lt;strong&gt;OAuth lets users sign in using an existing account (like GitHub or Google) without ever sharing their password with your application.&lt;/strong&gt; Instead, GitHub itself confirms the user's identity and hands your app a token proving it — your app never sees or stores their GitHub password.&lt;/p&gt;
&lt;p&gt;Since we're using GitHub, GitHub needs to know about your app before it'll let anyone sign in with it.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/settings/developers&quot;&gt;github.com/settings/developers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;New OAuth App&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Fill in:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Application name&lt;/strong&gt;: anything, e.g. &lt;code&gt;My App (Dev)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Homepage URL&lt;/strong&gt;: &lt;code&gt;http://localhost:3000&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authorization callback URL&lt;/strong&gt;: &lt;code&gt;http://localhost:3000/api/auth/callback/github&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Register application&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;Client ID&lt;/strong&gt;, then generate and copy a &lt;strong&gt;Client Secret&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; The callback URL pattern &lt;code&gt;/api/auth/callback/&amp;lt;provider&amp;gt;&lt;/code&gt; is fixed by Auth.js — don't change it, or GitHub won't be able to redirect back to your app correctly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-add-environment-variables&quot;&gt;Step 3: Add Environment Variables&lt;/h3&gt;
&lt;p&gt;Create a &lt;code&gt;.env.local&lt;/code&gt; file in your project root:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# .env.local
AUTH_SECRET=replace-this-with-a-random-string
AUTH_GITHUB_ID=your_github_client_id
AUTH_GITHUB_SECRET=your_github_client_secret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AUTH_SECRET&lt;/code&gt; is used to sign and encrypt session tokens — it must stay private. Generate a strong random value with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx auth secret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This command (from the &lt;code&gt;auth&lt;/code&gt; CLI that ships with &lt;code&gt;next-auth&lt;/code&gt;) automatically writes a secure &lt;code&gt;AUTH_SECRET&lt;/code&gt; into your &lt;code&gt;.env.local&lt;/code&gt; file for you.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting to add &lt;code&gt;.env.local&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;. If &lt;code&gt;AUTH_SECRET&lt;/code&gt; or your OAuth secret leaks into a public repo, attackers can forge valid sessions for your app.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-configure-auth-js&quot;&gt;Step 4: Configure Auth.js&lt;/h3&gt;
&lt;p&gt;This is the heart of the setup. Create an &lt;code&gt;auth.ts&lt;/code&gt; file inside &lt;code&gt;src/lib/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/lib/auth.ts
import NextAuth from &quot;next-auth&quot;;
import GitHub from &quot;next-auth/providers/github&quot;;

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [GitHub],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-s-happening-here-&quot;&gt;What's happening here?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;NextAuth(...)&lt;/code&gt; takes a config object and returns everything you need: &lt;code&gt;handlers&lt;/code&gt; (for the API route), &lt;code&gt;signIn&lt;/code&gt;/&lt;code&gt;signOut&lt;/code&gt; (Server Actions you can call from forms or buttons), and &lt;code&gt;auth()&lt;/code&gt; (a function to read the current session anywhere on the server).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;providers: [GitHub]&lt;/code&gt; tells Auth.js to use GitHub OAuth. Since our environment variables follow the &lt;code&gt;AUTH_GITHUB_ID&lt;/code&gt; / &lt;code&gt;AUTH_GITHUB_SECRET&lt;/code&gt; naming convention, Auth.js picks them up automatically — no need to pass them manually.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Want to add Google too? Just import &lt;code&gt;Google&lt;/code&gt; from &lt;code&gt;next-auth/providers/google&lt;/code&gt;, add matching &lt;code&gt;AUTH_GOOGLE_ID&lt;/code&gt; / &lt;code&gt;AUTH_GOOGLE_SECRET&lt;/code&gt; env variables, and drop it into the &lt;code&gt;providers&lt;/code&gt; array. Auth.js supports 80+ providers this way.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-create-the-api-route-handler&quot;&gt;Step 5: Create the API Route Handler&lt;/h3&gt;
&lt;p&gt;Auth.js needs a route to handle sign-in, sign-out, and OAuth callbacks. Create this file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from &quot;@/lib/auth&quot;;

export const { GET, POST } = handlers;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-a-catch-all-route-&quot;&gt;Why a catch-all route?&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;[...nextauth]&lt;/code&gt; folder name is a &lt;strong&gt;catch-all dynamic route&lt;/strong&gt; — it matches every path under &lt;code&gt;/api/auth/&lt;/code&gt;, like &lt;code&gt;/api/auth/signin&lt;/code&gt;, &lt;code&gt;/api/auth/callback/github&lt;/code&gt;, and &lt;code&gt;/api/auth/session&lt;/code&gt;. Instead of writing a route for each one, Auth.js's &lt;code&gt;handlers&lt;/code&gt; object already knows how to respond to all of them internally.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-add-the-proxy-middleware-&quot;&gt;Step 6: Add the Proxy (Middleware)&lt;/h3&gt;
&lt;p&gt;In Next.js 16, what used to be called &lt;code&gt;middleware.ts&lt;/code&gt; is now named &lt;code&gt;proxy.ts&lt;/code&gt; — same concept, same file conventions, just a renamed file. This is the ideal place to check authentication before a request even reaches your page.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/proxy.ts
export { auth as proxy } from &quot;@/lib/auth&quot;;

export const config = {
  matcher: [
    &quot;/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)&quot;,
  ],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-s-happening-here--1&quot;&gt;What's happening here?&lt;/h4&gt;
&lt;p&gt;Think of &lt;code&gt;proxy.ts&lt;/code&gt; like a &lt;strong&gt;security guard standing at the entrance of your app&lt;/strong&gt; — every matching request passes through it before reaching a page. By exporting &lt;code&gt;auth&lt;/code&gt; as &lt;code&gt;proxy&lt;/code&gt;, every request now has access to the session, which you can use to redirect unauthenticated users.&lt;/p&gt;
&lt;p&gt;By default, this doesn't block anything yet — it just makes auth checking available. Let's actually protect a route next.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-protect-a-route&quot;&gt;Step 7: Protect a Route&lt;/h3&gt;
&lt;p&gt;Let's say you have a &lt;code&gt;/dashboard&lt;/code&gt; page that only logged-in users should see, and you also want to bounce already-logged-in users away from your login/home page. Update &lt;code&gt;proxy.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/proxy.ts
import { NextResponse } from &quot;next/server&quot;;
import { auth } from &quot;@/lib/auth&quot;;

export default auth((req) =&amp;gt; {
    const isLoggedIn = !!req.auth;
    const { nextUrl } = req

    // Define routes that logged-in users shouldn't see
    const isAuthPage = nextUrl.pathname === &quot;/login&quot; || nextUrl.pathname === &quot;/&quot;

    if (isAuthPage) {
        if (isLoggedIn) {
            // Redirect authenticated users to the dashboard
            return NextResponse.redirect(new URL(&quot;/dashboard&quot;, nextUrl))
        }
        return NextResponse.next()
    }

    // Protect private routes
    if (!isLoggedIn &amp;amp;&amp;amp; nextUrl.pathname.startsWith(&quot;/dashboard&quot;)) {
        return NextResponse.redirect(new URL(&quot;/&quot;, nextUrl))
    }
});

export const config = {
    matcher: [
        &quot;/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)&quot;,
    ]
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;breaking-this-down&quot;&gt;Breaking this down&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;auth((req) =&amp;gt; {...})&lt;/code&gt; wraps your proxy function, giving you &lt;code&gt;req.auth&lt;/code&gt; — the current session, or &lt;code&gt;null&lt;/code&gt; if nobody's logged in.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isAuthPage&lt;/code&gt; covers routes a logged-in user has no reason to revisit (like &lt;code&gt;/login&lt;/code&gt; or your home page) — if they're already signed in, we send them straight to &lt;code&gt;/dashboard&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;The second check does the opposite: if someone isn't logged in and tries to visit &lt;code&gt;/dashboard&lt;/code&gt;, we redirect them back to &lt;code&gt;/&lt;/code&gt; instead of letting the request through.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Relying on &lt;code&gt;proxy.ts&lt;/code&gt; alone for security. Middleware-level checks are great for UX (redirecting before a page even loads), but you should &lt;em&gt;also&lt;/em&gt; verify the session inside Server Actions and Route Handlers that touch sensitive data — never trust the frontend redirect as your only line of defense.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-8-add-sign-in-sign-out-buttons&quot;&gt;Step 8: Add Sign In / Sign Out Buttons&lt;/h3&gt;
&lt;p&gt;Auth.js exposes &lt;code&gt;signIn&lt;/code&gt; and &lt;code&gt;signOut&lt;/code&gt; as &lt;strong&gt;Server Actions&lt;/strong&gt;, so you can call them directly from a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; without any client-side JavaScript.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/components/auth-buttons.tsx
import { auth, signIn, signOut } from &quot;@/lib/auth&quot;;

export default async function AuthButtons() {
  const session = await auth();

  if (!session?.user) {
    return (
      &amp;lt;form
        action={async () =&amp;gt; {
          &quot;use server&quot;;
          await signIn(&quot;github&quot;);
        }}
      &amp;gt;
        &amp;lt;button
          type=&quot;submit&quot;
          className=&quot;rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground&quot;
        &amp;gt;
          Sign in with GitHub
        &amp;lt;/button&amp;gt;
      &amp;lt;/form&amp;gt;
    );
  }

  return (
    &amp;lt;form
      action={async () =&amp;gt; {
        &quot;use server&quot;;
        await signOut();
      }}
    &amp;gt;
      &amp;lt;button
        type=&quot;submit&quot;
        className=&quot;rounded-lg border px-4 py-2 text-sm font-medium&quot;
      &amp;gt;
        Sign out
      &amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-s-happening-here--2&quot;&gt;What's happening here?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AuthButtons&lt;/code&gt; is an &lt;strong&gt;async Server Component&lt;/strong&gt; — it calls &lt;code&gt;auth()&lt;/code&gt; directly to check whether someone's logged in, no &lt;code&gt;useEffect&lt;/code&gt; or loading state needed.&lt;/li&gt;
&lt;li&gt;The inline &lt;code&gt;&quot;use server&quot;&lt;/code&gt; function inside &lt;code&gt;action={...}&lt;/code&gt; is an &lt;strong&gt;inline Server Action&lt;/strong&gt;. When the form is submitted, this function runs on the server and calls &lt;code&gt;signIn(&quot;github&quot;)&lt;/code&gt; or &lt;code&gt;signOut()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;signIn(&quot;github&quot;)&lt;/code&gt; tells Auth.js exactly which provider to start the OAuth flow with — useful once you add more than one provider.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Drop this component into your navbar, and you've got a fully working auth toggle with zero client-side JavaScript.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-9-read-the-session-in-a-server-component&quot;&gt;Step 9: Read the Session in a Server Component&lt;/h3&gt;
&lt;p&gt;Anywhere on the server — a page, a layout, another component — you can call &lt;code&gt;auth()&lt;/code&gt; to get the current user:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/app/dashboard/page.tsx
import { auth } from &quot;@/lib/auth&quot;;

export default async function DashboardPage() {
  const session = await auth();

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1 className=&quot;text-2xl font-bold&quot;&amp;gt;Dashboard&amp;lt;/h1&amp;gt;
      &amp;lt;p className=&quot;text-muted-foreground&quot;&amp;gt;
        Welcome back, {session?.user?.name}!
      &amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since we already protect &lt;code&gt;/dashboard&lt;/code&gt; in &lt;code&gt;proxy.ts&lt;/code&gt;, &lt;code&gt;session&lt;/code&gt; is guaranteed to exist by the time this page renders — but it's still good practice to use optional chaining (&lt;code&gt;session?.user?.name&lt;/code&gt;) in case that logic ever changes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-10-protect-a-server-action-directly&quot;&gt;Step 10: Protect a Server Action Directly&lt;/h3&gt;
&lt;p&gt;Even with &lt;code&gt;proxy.ts&lt;/code&gt; in place, always double-check authentication inside anything that mutates data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/actions/create-post.ts
&quot;use server&quot;;

import { auth } from &quot;@/lib/auth&quot;;

export async function createPost(formData: FormData) {
  const session = await auth();

  if (!session?.user) {
    return { error: &quot;You must be signed in to do that.&quot; };
  }

  const title = formData.get(&quot;title&quot;) as string;

  // Your database logic here
  // await db.post.create({ data: { title, authorId: session.user.id } })

  return { success: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the same idea as validating input on the server even though your form already validates on the client — never trust that a request only came from your UI.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-2&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;is-auth-js-free-&quot;&gt;Is Auth.js free?&lt;/h4&gt;
&lt;p&gt;Yes, completely. Auth.js is open source and has no usage limits, unlike some hosted auth providers that charge per monthly active user. You're only responsible for hosting and maintaining your own database if you want persistent user records.&lt;/p&gt;
&lt;h4 id=&quot;why-is-auth-js-still-installed-as-next-auth-&quot;&gt;Why is Auth.js still installed as &lt;code&gt;next-auth&lt;/code&gt;?&lt;/h4&gt;
&lt;p&gt;Because the npm package name never changed, even though the project itself rebranded. Renaming a package that thousands of projects already depend on would break every existing install, so the team kept &lt;code&gt;next-auth&lt;/code&gt; as the package name and just changed the project's public name and docs to Auth.js. You'll see both names used interchangeably — they refer to the exact same library.&lt;/p&gt;
&lt;h4 id=&quot;why-did-nextauth-become-auth-js-&quot;&gt;Why did NextAuth become Auth.js?&lt;/h4&gt;
&lt;p&gt;NextAuth started out Next.js-only, but the maintainers wanted the same authentication core to work with other frameworks too — SvelteKit, Express, and more, via a shared &lt;code&gt;@auth/core&lt;/code&gt; package. &quot;NextAuth&quot; no longer described what the project did, so it was renamed to the framework-agnostic &lt;strong&gt;Auth.js&lt;/strong&gt;, with &lt;code&gt;next-auth&lt;/code&gt; continuing on as the Next.js-specific package built on top of that shared core.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-a-database-&quot;&gt;Do I need a database?&lt;/h4&gt;
&lt;p&gt;Not for OAuth-only sign-in like the GitHub example above — Auth.js can run in a stateless JWT session mode with zero database. You'll need a database (with an Auth.js &lt;strong&gt;adapter&lt;/strong&gt;, like the Drizzle or Prisma adapter) if you want persistent user records, email/password login, or multiple linked accounts per user.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-difference-between-auth-js-and-clerk-&quot;&gt;What's the difference between Auth.js and Clerk?&lt;/h4&gt;
&lt;p&gt;Auth.js gives you full ownership of your data — everything lives in your own database (or nowhere, if you're stateless) — but you build your own UI. Clerk is a hosted service with prebuilt sign-in components and a dashboard, which is faster to set up but means your user data lives on Clerk's infrastructure. If data ownership matters for compliance reasons, Auth.js is the safer default.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 If you're deciding between the two, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026&lt;/a&gt; for a full comparison.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;can-i-add-email-password-login-instead-of-oauth-&quot;&gt;Can I add email/password login instead of OAuth?&lt;/h4&gt;
&lt;p&gt;Yes, using the &lt;code&gt;Credentials&lt;/code&gt; provider. It requires you to write your own password hashing and verification logic (Auth.js won't do this for you, since it's security-sensitive), and you'll need a database to store user records.&lt;/p&gt;
&lt;h4 id=&quot;why-is-my-session-null-even-after-signing-in-&quot;&gt;Why is my session &lt;code&gt;null&lt;/code&gt; even after signing in?&lt;/h4&gt;
&lt;p&gt;This is almost always a mismatched &lt;code&gt;AUTH_SECRET&lt;/code&gt; or an incorrect callback URL. Double-check that your GitHub OAuth app's callback URL exactly matches &lt;code&gt;http://localhost:3000/api/auth/callback/github&lt;/code&gt; (or your production domain), and that &lt;code&gt;.env.local&lt;/code&gt; was loaded — restart your dev server after any environment variable change.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-2&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;You've now got a complete Auth.js v5 setup in Next.js 16 — GitHub OAuth sign-in, route protection via &lt;code&gt;proxy.ts&lt;/code&gt;, and session access in both Server Components and Server Actions. This same pattern extends cleanly: add more providers, connect a database adapter for persistent users, or layer role-based access control on top of the session object.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is wiring up a database adapter so user accounts persist across sign-ins, or adding a second provider like Google for more sign-in options.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/next-auth-setup&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;useful-resources&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://authjs.dev&quot;&gt;Auth.js Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://authjs.dev/getting-started/installation&quot;&gt;Auth.js Next.js 16 / App Router Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://authjs.dev/getting-started/providers/github&quot;&gt;Auth.js GitHub Provider Docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;continue-learning-2&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;How to Add Clerk Authentication in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;helpful-free-tools&quot;&gt;Helpful Free Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/meta-tag-generator&quot;&gt;Meta Tag Generator for Next.js &amp;amp; HTML&lt;/a&gt; — generate SEO meta tags, Open Graph tags, Twitter Cards, canonical URLs, and JSON-LD structured data instantly. Handy for wiring up per-post metadata like the &lt;code&gt;generateMetadata()&lt;/code&gt; function above.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — if your next project pairs this blog setup with a Supabase backend, generate secure, performance-optimized Row Level Security policies in seconds.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod Schema Generator&lt;/a&gt; — generate Zod schemas instantly from JSON or TypeScript. Includes smart inference, live validation, React Hook Form, Next.js API Routes, and Server Actions.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24071/how-to-add-auth-js-nextauth-v5-authentication-in-next-js-16-2026</guid>
<pubDate>Tue, 04 Aug 2026 04:46:25 +0000</pubDate>
</item>
<item>
<title>Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?</title>
<link>https://coderlegion.com/23551/clerk-vs-nextauth-auth-js-in-2026-which-one-should-you-choose</link>
<description>&lt;p&gt;If you're starting a new Next.js project in 2026, picking an auth solution isn't as simple as it used to be. &lt;strong&gt;NextAuth has been renamed to Auth.js&lt;/strong&gt;, it shipped a near-complete rewrite in v5, and &lt;strong&gt;Clerk&lt;/strong&gt; has kept pushing further into the &quot;auth + user management&quot; space rather than just sign-in forms.&lt;/p&gt;
&lt;p&gt;A lot of comparison posts floating around are still talking about NextAuth v4 and Clerk's old pricing, neither of which reflects what's actually shipping today. This guide compares the current versions, what changed, and which one makes sense for your project.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer-1&quot;&gt;Quick Answer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Choose Clerk&lt;/strong&gt; if you want the fastest setup, prebuilt UI components, and built-in user management features such as organizations, session management, and administrative tooling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Auth.js v5&lt;/strong&gt; if you want full control over your data, no vendor lock-in, and you're comfortable building your own UI and database schema.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now let's get into why.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-changed-nextauth-became-auth-js&quot;&gt;What Changed: NextAuth Became Auth.js&lt;/h3&gt;
&lt;p&gt;If you haven't followed the project closely, here's the short version:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The package is still installed as &lt;code&gt;next-auth&lt;/code&gt; on npm, but the project rebranded to &lt;strong&gt;Auth.js&lt;/strong&gt; and now also supports frameworks beyond Next.js (SvelteKit, Express, etc.) through &lt;code&gt;@auth/core&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v5&lt;/strong&gt; is a full rewrite. The old &lt;code&gt;pages/api/auth/[...nextauth].ts&lt;/code&gt; + &lt;code&gt;authOptions&lt;/code&gt; pattern is gone. Everything is now exported from a single &lt;code&gt;NextAuth()&lt;/code&gt; call.&lt;/li&gt;
&lt;li&gt;Auth.js is no longer an independent project — it's now maintained under &lt;strong&gt;Better Auth Inc.&lt;/strong&gt;, after Better Auth (a newer, fast-growing auth library) effectively absorbed the project.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// auth.ts
import NextAuth from &quot;next-auth&quot;;
import GitHub from &quot;next-auth/providers/github&quot;;

export const { auth, handlers } = NextAuth({
  providers: [GitHub],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/auth/[...nextauth]/route.ts
import { handlers } from &quot;@/auth&quot;;
export const { GET, POST } = handlers;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts
export { auth as proxy } from &quot;@/auth&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No more separate &lt;code&gt;getServerSession()&lt;/code&gt; import — you just call &lt;code&gt;auth()&lt;/code&gt; anywhere on the server.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-changed-clerk&quot;&gt;What Changed: Clerk&lt;/h3&gt;
&lt;p&gt;Clerk hasn't rewritten its API the way Auth.js did, but a few things are different from what older tutorials show:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Middleware is now &lt;code&gt;proxy.ts&lt;/code&gt;&lt;/strong&gt; in Next.js 16 (it was &lt;code&gt;middleware.ts&lt;/code&gt; in Next.js ≤15 — same code, new filename).&lt;/li&gt;
&lt;li&gt;Clerk ships a &lt;code&gt;&amp;lt;Show when=&quot;signed-in&quot;&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;Show when=&quot;signed-out&quot;&amp;gt;&lt;/code&gt; component pattern now, replacing the older &lt;code&gt;&amp;lt;SignedIn&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;SignedOut&amp;gt;&lt;/code&gt; components in newer examples.&lt;/li&gt;
&lt;li&gt;The free tier was raised to &lt;strong&gt;50,000 monthly active users&lt;/strong&gt;, making it viable for a lot more side projects and early-stage SaaS apps than it used to be.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts
import { clerkMiddleware } from &quot;@clerk/nextjs/server&quot;;

export default clerkMiddleware();

export const config = {
  matcher: [
    &quot;/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)&quot;,
    &quot;/(api|trpc)(.*)&quot;,
    &quot;/__clerk/(.*)&quot;,
  ],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default &lt;code&gt;clerkMiddleware()&lt;/code&gt; doesn't protect anything — you opt routes into protection explicitly, which is worth knowing before you assume your dashboard is locked down.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup-time-1&quot;&gt;Setup Time&lt;/h3&gt;
&lt;p&gt;This is still the biggest practical difference.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Clerk&lt;/strong&gt;: install the package, drop your publishable/secret keys into &lt;code&gt;.env&lt;/code&gt;, add &lt;code&gt;clerkMiddleware()&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt;, wrap your layout in &lt;code&gt;&amp;lt;ClerkProvider&amp;gt;&lt;/code&gt;. You get a working sign-in/sign-up flow, hosted by Clerk's Account Portal, in well under 15 minutes — no database, no schema, no email provider setup.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @clerk/nextjs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Auth.js v5&lt;/strong&gt;: install the package, create &lt;code&gt;auth.ts&lt;/code&gt;, set up a route handler, configure a database adapter if you want persistent sessions (Prisma, Drizzle, etc.), and build your own sign-in UI since Auth.js ships no components. Realistically an hour or more for a first-time setup, more if you're also wiring up a database.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install next-auth@beta
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you're optimizing purely for &quot;ship the login screen today,&quot; Clerk wins. If you're fine writing more code in exchange for control, Auth.js works.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;customization-amp-branding&quot;&gt;Customization &amp;amp; Branding&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Clerk&lt;/strong&gt; gives you prebuilt components (&lt;code&gt;&amp;lt;SignIn /&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;SignUp /&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;UserButton /&amp;gt;&lt;/code&gt;) that are themeable but still recognizably Clerk's UI unless you build fully custom flows using their lower-level APIs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth.js&lt;/strong&gt; gives you zero UI. You design every form, every error state, every redirect. More work, but your auth pages look exactly like the rest of your app with no extra effort to &quot;de-Clerk&quot; them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If brand consistency matters a lot (e.g. an enterprise client app), Auth.js or a fully custom Clerk flow both work — Clerk's default components just need more styling effort to disappear into your design system.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;data-ownership-amp-vendor-lock-in&quot;&gt;Data Ownership &amp;amp; Vendor Lock-In&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Clerk&lt;/strong&gt; stores your users on its infrastructure. You get a dashboard, audit logs, and organization management for free, but your user data lives outside your own database unless you sync it via webhooks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth.js&lt;/strong&gt; stores sessions and users wherever you point your adapter — your own Postgres, MySQL, MongoDB, whatever. You own the schema and the data outright.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For SaaS products where &quot;we never see our own user data&quot; is a hard no from a client or compliance team, Auth.js (or self-hosted alternatives like Better Auth) is the safer default.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;security-the-middleware-caveat&quot;&gt;Security: The Middleware Caveat&lt;/h3&gt;
&lt;p&gt;Worth knowing regardless of which library you pick: &lt;strong&gt;middleware-only session protection in Next.js has had a documented bypass&lt;/strong&gt; (CVE-2025-29927), where a spoofed &lt;code&gt;x-middleware-subrequest&lt;/code&gt; header could skip middleware checks entirely. Next.js patched it, but it's a good reminder not to rely on &lt;code&gt;proxy.ts&lt;/code&gt; alone.&lt;/p&gt;
&lt;p&gt;Practical takeaway for both libraries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Re-verify the session inside Server Actions and Route Handlers, not just in &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Treat proxy-level redirects as a UX nicety, not your only security boundary.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// inside a Server Action, regardless of auth library
const session = await auth(); // or currentUser() for Clerk

if (!session) {
  return { error: &quot;Unauthorized&quot; };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pricing-1&quot;&gt;Pricing&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Clerk &lt;/th&gt;&lt;th&gt;Auth.js (v5)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;License &lt;/td&gt;&lt;td&gt;Free up to 50,000 MAU, paid tiers after &lt;/td&gt;&lt;td&gt;Free, open source&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hosting cost &lt;/td&gt;&lt;td&gt;None for auth itself &lt;/td&gt;&lt;td&gt;You pay for your own DB/hosting&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hidden costs &lt;/td&gt;&lt;td&gt;Add-ons (orgs, billing) may cost extra at scale &lt;/td&gt;&lt;td&gt;Dev time to build what Clerk gives free&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Auth.js is &quot;free&quot; in license terms, but you're trading subscription cost for development time — building your own password reset flow, email verification, and admin tooling isn't free, it's just paid in hours instead of dollars.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;feature-comparison-1&quot;&gt;Feature Comparison&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;Clerk &lt;/th&gt;&lt;th&gt;Auth.js v5&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Setup Time &lt;/td&gt;&lt;td&gt;⭐⭐⭐⭐⭐ &lt;/td&gt;&lt;td&gt;⭐⭐⭐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Prebuilt UI &lt;/td&gt;&lt;td&gt;Yes &lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data Ownership &lt;/td&gt;&lt;td&gt;Hosted by Clerk &lt;/td&gt;&lt;td&gt;Fully yours&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Organizations / Teams &lt;/td&gt;&lt;td&gt;Built-in &lt;/td&gt;&lt;td&gt;Build it yourself&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OAuth Providers &lt;/td&gt;&lt;td&gt;Built-in, dashboard config &lt;/td&gt;&lt;td&gt;Built-in, code config&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Customization &lt;/td&gt;&lt;td&gt;Good, more work for full control &lt;/td&gt;&lt;td&gt;Full, by default&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Best For &lt;/td&gt;&lt;td&gt;MVPs, SaaS, fast launches &lt;/td&gt;&lt;td&gt;Apps needing full data ownership&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Maintenance &lt;/td&gt;&lt;td&gt;Low &lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-should-you-use-clerk-&quot;&gt;When Should You Use Clerk?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You're building an MVP and want auth solved in an afternoon.&lt;/li&gt;
&lt;li&gt;You need organizations/teams (B2B SaaS) without building that system yourself.&lt;/li&gt;
&lt;li&gt;You're under the 50K MAU free tier and want to stay focused on your actual product.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-should-you-use-auth-js-v5-&quot;&gt;When Should You Use Auth.js v5?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You're migrating an existing NextAuth v4 codebase (in which case, this is still your path forward).&lt;/li&gt;
&lt;li&gt;Data residency or compliance requirements mean user data must live in your own database.&lt;/li&gt;
&lt;li&gt;You want to avoid per-user authentication pricing as you scale.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;Note: if you're starting completely fresh with no NextAuth history, it's worth also looking at &lt;strong&gt;Better Auth&lt;/strong&gt;, since it's now the team behind Auth.js itself and is increasingly positioned as the recommended path for new self-hosted projects.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-recommendation&quot;&gt;Final Recommendation&lt;/h3&gt;
&lt;p&gt;For most freelance and SaaS MVP work in 2026, &lt;strong&gt;Clerk remains the fastest way to ship&lt;/strong&gt;, especially if the project needs organizations or billing-adjacent auth features later. &lt;strong&gt;Auth.js v5&lt;/strong&gt; is the right call when a client specifically needs full ownership of user data, or when you're maintaining a codebase that already has NextAuth baked in.&lt;/p&gt;
&lt;p&gt;Neither choice is permanent — plenty of teams start with Clerk to validate an idea and migrate to a self-hosted option once they have real scale and real requirements.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;useful-resources-1&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://clerk.com/docs&quot;&gt;Clerk Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://clerk.com/docs/nextjs/getting-started/quickstart&quot;&gt;Clerk Next.js Quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://authjs.dev&quot;&gt;Auth.js Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://authjs.dev/getting-started/migrating-to-v5&quot;&gt;Auth.js Migration Guide (v4 to v5)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;continue-learning-3&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;p&gt;If you'd like a full step-by-step implementation guide, check out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;How to Add Clerk Authentication in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23551/clerk-vs-nextauth-auth-js-in-2026-which-one-should-you-choose</guid>
<pubDate>Mon, 27 Jul 2026 05:32:59 +0000</pubDate>
</item>
</channel>
</rss>