Best Next.js Authentication Solutions in 2026
TL;DR
Auth.js (NextAuth v5) for self-hosted OAuth; Clerk for full-featured managed auth; Better Auth for type-safe self-hosted. Auth.js (~2.5M weekly downloads) is the standard Next.js auth library — handles OAuth in minutes but requires database setup for sessions. Clerk (~300K) is managed auth as a service with beautiful pre-built UI — $0 for 10K monthly users. Better Auth (~100K, fast-growing) is a newer TypeScript-first alternative to NextAuth with better type safety and plugin system.
Key Takeaways
- Auth.js v5: ~2.5M weekly downloads — App Router, Server Actions, edge compatible
- Clerk: ~300K downloads — managed, pre-built UI, $0 for 10K users/month
- Better Auth: ~100K downloads — type-safe, plugin architecture, newer alternative
- Lucia v3 — session management library (not full auth, you build on top)
- NextAuth v4 → v5 — major API change; v5 is Auth.js, works across frameworks
What Actually Matters When Picking Auth for Next.js
Authentication sounds like a solved problem until you're two weeks into implementation and hit your first edge case. The decisions that matter most aren't OAuth provider support (all four options handle GitHub and Google) — they're the boring infrastructure questions: Where do sessions live? What happens when a user's session expires mid-request? How do you pass user identity to Server Components? How does your auth middleware interact with Next.js's edge runtime?
In 2026, with Next.js App Router as the default, auth decisions are more consequential than ever. Server Components can read sessions directly, but only if your auth library supports synchronous session access in RSC context. Middleware that runs on the edge needs auth tokens that don't require database calls. Server Actions need CSRF protection that the auth library should provide. These aren't theoretical concerns — they're the real reasons developers switch auth solutions mid-project.
The comparison below addresses these practical questions, not just "does it support Google login."
Auth.js v5 (Next.js Standard)
// Auth.js v5 — setup
// auth.ts (project root)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db';
import { verifyPassword } from '@/lib/crypto';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db), // Persist sessions to DB
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
credentials: {
email: { type: 'email' },
password: { type: 'password' },
},
authorize: async ({ email, password }) => {
const user = await db.query.users.findFirst({
where: eq(users.email, email as string),
});
if (!user) return null;
const valid = await verifyPassword(password as string, user.passwordHash);
return valid ? user : null;
},
}),
],
callbacks: {
session({ session, user }) {
session.user.id = user.id;
session.user.role = user.role;
return session;
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
});
// Auth.js v5 — App Router: route handlers
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
// Auth.js v5 — middleware (protect routes)
// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith('/login');
const isProtected = req.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.url));
}
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', req.url));
}
});
export const config = {
matcher: ['/dashboard/:path*', '/login'],
};
// Auth.js v5 — Server Component auth check
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect('/login');
return (
<div>
<p>Welcome, {session.user?.name}</p>
<p>Role: {session.user?.role}</p>
</div>
);
}
Auth.js v5 solved the App Router problem that plagued v4 users: sessions are now accessible in Server Components with a simple await auth() call, and the middleware integration wraps cleanly around Next.js's middleware pattern. The database adapter system is extensive — Drizzle, Prisma, MongoDB, Supabase, Firebase, and more.
The main complexity with Auth.js is credential-based auth. OAuth flows are genuinely simple, but adding email/password authentication requires more setup: you need to handle password hashing (Auth.js doesn't do it for you), email verification, and password reset flows. For projects where users sign up with email/password, this adds 2-4 hours of additional implementation versus Clerk's built-in flows.
Auth.js runs at the edge by default — sessions use JWTs that don't require a database round-trip in middleware. This makes it well-suited for Vercel deployments where middleware runs on Cloudflare's edge network globally.
Clerk (Managed Auth)
// Clerk — setup in Next.js App Router
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isPublicRoute = createRouteMatcher(['/', '/sign-in(.*)', '/sign-up(.*)']);
export default clerkMiddleware((auth, req) => {
if (!isPublicRoute(req)) auth().protect();
});
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
// Clerk — wrap app
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html>
<body>{children}</body>
</html>
</ClerkProvider>
);
}
// Clerk — pre-built UI components (zero custom UI needed)
import { SignIn, SignUp, UserButton, SignedIn, SignedOut } from '@clerk/nextjs';
// Ready-made sign-in page
export default function SignInPage() {
return <SignIn />;
}
// Conditional rendering
export function Header() {
return (
<header>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
<SignedOut>
<a href="/sign-in">Sign In</a>
</SignedOut>
</header>
);
}
// Clerk — server-side user access
import { auth, currentUser } from '@clerk/nextjs/server';
export default async function ProfilePage() {
const { userId } = auth();
if (!userId) redirect('/sign-in');
const user = await currentUser();
return (
<div>
<p>{user?.firstName} {user?.lastName}</p>
<p>{user?.emailAddresses[0]?.emailAddress}</p>
</div>
);
}
Clerk is the fastest path from zero to working authentication in Next.js. The <SignIn /> and <SignUp /> components handle email verification, social OAuth, MFA, and error states without any custom UI. For startups and solo developers, this removes 1-2 weeks of auth UI work.
The pricing model is straightforward but requires planning: $0 for up to 10,000 monthly active users, then $0.02 per MAU for the Pro plan. A 50,000 MAU application pays ~$800/month just for auth. This cost is reasonable for the value delivered, but it's a meaningful line item that self-hosted alternatives eliminate.
Clerk's standout features in 2026 are organization management (multi-tenant apps), impersonation (support tools), and webhooks for sync events. If your app needs team workspaces, Clerk handles the entire user-to-organization mapping with a few lines of configuration. Building the same thing with Auth.js requires significant database schema work and custom logic.
The one architectural concern with Clerk is data ownership: user data lives in Clerk's infrastructure. For regulated industries (healthcare, finance, government), this may be a compliance blocker. Clerk does offer enterprise plans with data residency options, but it's a real consideration.
Better Auth
// Better Auth — type-safe auth setup
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '@/db';
import { nextCookies } from 'better-auth/next-js';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [nextCookies()],
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;
Better Auth launched in late 2024 and reached ~100K weekly downloads by March 2026, making it one of the fastest-growing auth libraries in the Node.js ecosystem. The core differentiator is TypeScript inference: where Auth.js requires manual type augmentation to add custom fields to sessions, Better Auth infers session types automatically from your configuration.
The plugin architecture is Better Auth's other selling point. Passkeys, magic links, organization management, admin controls, and rate limiting are available as first-party plugins with clean integration points. Each plugin is typed end-to-end — adding a plugin changes the inferred types throughout your application without manual type declarations.
Better Auth is still maturing. The documentation is good but less comprehensive than Auth.js v5, the adapter support is narrower, and the community is smaller. For greenfield projects prioritizing TypeScript quality, it's an excellent choice. For teams joining an existing Auth.js project, migration isn't warranted for type ergonomics alone.
Lucia (Session Primitive)
Worth mentioning separately: Lucia v3 is not an auth library in the same sense — it's a session management primitive. Lucia handles session creation, validation, and invalidation without opinionating on OAuth providers, database schemas, or UI. It's the lowest-level choice, requiring you to implement OAuth flows, email verification, and UI yourself.
The target audience is developers who want complete control over every aspect of their auth implementation. Lucia is genuinely excellent at what it does — the session management is secure by default and works with any database. But "complete control" means 3-5x more implementation work than Auth.js for similar functionality.
Feature Comparison
| Feature | Auth.js v5 | Clerk | Better Auth | Lucia v3 |
|---|---|---|---|---|
| Weekly downloads | ~2.5M | ~300K | ~100K | ~80K |
| OAuth providers | 40+ | 15+ | 20+ | Manual |
| Email/password | Plugin | ✅ Built-in | ✅ Built-in | Manual |
| Pre-built UI | ❌ | ✅ | ❌ | ❌ |
| Organization/teams | ❌ | ✅ | Plugin | ❌ |
| MFA/2FA | ❌ | ✅ | Plugin | Manual |
| Edge compatible | ✅ | ✅ | ✅ | ✅ |
| Self-hosted | ✅ | ❌ | ✅ | ✅ |
| TypeScript types | Manual augment | Good | Excellent | Good |
| Monthly cost | $0 (hosting cost) | Free <10K MAU | $0 | $0 |
Security Considerations
All four libraries handle the critical security concerns — CSRF protection, secure cookie settings, and session rotation — but with different defaults.
Auth.js v5 uses JWTs by default when no database adapter is configured, and database sessions when an adapter is present. Database sessions are more revocable but require a DB round-trip; JWT sessions are faster but can't be immediately invalidated without a token blocklist.
Clerk manages session security entirely — you don't configure it, which removes the risk of misconfiguration but also removes control. Clerk's security track record is strong.
Better Auth uses database sessions with automatic rotation, which provides good revocability while keeping latency acceptable.
Lucia's session security is excellent — the library's primary purpose is getting session management right — but you're responsible for the security of every surrounding layer.
When to Choose
| Scenario | Pick |
|---|---|
| New Next.js app, want zero auth work | Clerk |
| Budget-conscious, under 10K users | Clerk (free) |
| Self-hosted, data stays yours | Auth.js v5 |
| Best TypeScript inference | Better Auth |
| Social OAuth only (GitHub, Google) | Auth.js v5 (simplest) |
| Organization/team management | Clerk |
| Custom auth flows, complex requirements | Auth.js v5 or Better Auth |
| Edge runtime required | Auth.js v5 (edge-compatible) |
| Complete control, security primitive | Lucia v3 |
| Regulated industry, data residency | Auth.js v5 or Better Auth |
Testing Authentication in Next.js
Authentication is one of the most undertested layers in Next.js applications. The combination of server components, edge middleware, and cookie-based sessions creates multiple failure modes that unit tests miss entirely.
Testing Auth.js
Auth.js v5's session functions can be mocked in Vitest with module mocking:
// Mock auth() for an authenticated user
vi.mock('@/auth', () => ({
auth: vi.fn().mockResolvedValue({
user: { id: '1', name: 'Alice', email: 'alice@example.com' },
}),
}));
// Mock auth() for an unauthenticated state
vi.mock('@/auth', () => ({
auth: vi.fn().mockResolvedValue(null),
}));
Integration tests for Auth.js middleware require simulating NextRequest objects with the correct headers and cookies. The middleware intercepts requests at the edge level, so testing it properly requires the full request lifecycle rather than unit-testing individual handlers.
Testing Clerk
Clerk provides @clerk/testing helpers for common test scenarios:
import { mockCurrentUser } from '@clerk/testing/jest';
const cleanup = mockCurrentUser({
id: 'user_123',
firstName: 'Alice',
emailAddresses: [{ emailAddress: 'alice@example.com' }],
});
// Your test assertions here
afterEach(() => cleanup());
The pre-built UI components (<SignIn />, <SignUp />) render in tests with minimal setup, though they require the ClerkProvider wrapper in the component tree.
End-to-End Auth Testing
Unit tests for authentication are necessary but not sufficient. Sessions, cookies, and CSRF tokens involve real HTTP semantics that are hard to fake accurately. Playwright's ability to save and restore browser storage state enables realistic E2E auth flows:
// tests/auth.setup.ts — run once, save session
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/sign-in');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'testpassword');
await page.click('[type=submit]');
await page.waitForURL('/dashboard');
// Save signed-in state
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Subsequent test files reference the saved session state and skip the login flow entirely:
// tests/dashboard.spec.ts
import { test } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/user.json' });
test('dashboard loads for authenticated user', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
What to Test vs. What to Trust
A practical heuristic: test the authorization logic you write; trust the authentication libraries you import. Clerk's session management doesn't need to be tested — it's covered by Clerk's own test suite. What needs testing is your application's behavior given different session states: what renders when userId is null, which routes redirect unauthenticated users, whether admin-only pages correctly block regular users.
For comprehensive test runner setup covering both auth unit tests and integration tests for route protection, Vitest handles both layers with a single configuration. Its vi.mock() hoisting behavior matches Jest, so Auth.js and Clerk mocking patterns from the Jest ecosystem transfer directly. Bun test works for pure server-side auth logic tests (session validation, role checks), but Vitest's jsdom integration is more complete for testing middleware and React components in the same suite.
Session Management and Security Trade-offs
Authentication choices affect security, performance, and user experience in ways that aren't always apparent at setup time. Understanding the session management strategy behind each library helps you choose the right one for your threat model.
JWT vs Database Sessions
Auth.js v5 supports both strategies. JWTs store session state in a signed cookie on the client — no database lookup needed per request. Any server can verify a JWT without coordinating with a central store, making them fast and stateless. The security trade-off is that JWTs are hard to invalidate before expiry. If a token is compromised or you need to force-logout all sessions after a password reset, you either wait for expiry or maintain a blocklist — reintroducing the database lookup you were avoiding.
Database sessions store a session ID in a cookie and keep session data in your database. Every authenticated request queries the session table. This is slower but enables immediate revocation: delete the row and the session is gone. For applications where security is paramount — financial, medical, enterprise — database sessions are the correct default despite latency costs.
Clerk uses its own session infrastructure with short-lived JWT tokens (60-second expiry) that auto-rotate. This provides near-immediate revocation (within one rotation cycle) while maintaining the performance characteristics of JWT verification. It's a thoughtful middle ground, though it introduces dependency on Clerk's uptime for session validation.
Token Rotation and Refresh
Long-lived refresh tokens paired with short-lived access tokens are the current security best practice for web applications. Auth.js v5 implements this pattern with database sessions — sessions refresh automatically when the user is active. Better Auth's token rotation is configurable with explicit accessTokenExpiresIn and refreshTokenExpiresIn settings.
For applications using JWTs with Auth.js, the default configuration does not rotate tokens — the JWT is valid for its full lifetime (30 days by default). To implement rotation, you need the jwt and session callbacks to issue shorter-lived tokens and use the update mechanism to refresh them. This is documented but non-trivial to implement correctly.
Middleware and Edge Authentication
Next.js App Router changed how authentication middleware works. With Auth.js v5, the auth() function runs in middleware, server components, and API routes. In middleware, it runs on the Edge runtime — Vercel Edge Network, Cloudflare Workers — with sub-millisecond session checks. Protected routes can redirect unauthenticated users at the CDN level, before the Next.js server processes the request.
Clerk's middleware is similarly Edge-compatible, with a simpler API: clerkMiddleware() wraps your middleware function and handles session checks automatically. The trade-off is that Clerk requires an outbound JWKS fetch on cold starts, adding latency on the first request after deployment.
Rate Limiting and Brute Force Protection
Neither Auth.js nor Better Auth include built-in rate limiting for authentication routes. Your sign-in endpoint can be attacked by brute force without any built-in protection. You need to add rate limiting yourself — via middleware using Redis (Upstash works well with Next.js Edge) or via a WAF rule in your CDN. Clerk handles this at the service level, applying rate limits automatically to authentication endpoints. For teams that don't want to implement rate limiting manually, this is a meaningful operational advantage for Clerk.
Compare auth library download trends on PkgPulse. Related: Best React Component Libraries 2026, Best Next.js CMS Options, and Next.js boilerplates with auth included if you want MakerKit or SupaStarter pre-configured with your chosen auth solution.
See the live comparison
View nextauth vs. clerk on PkgPulse →