Auth.js vs BetterAuth: The Ultimate Comparison Guide for Next.js


Never miss an update
Subscribe to receive news and special offers.
By subscribing you agree to our Privacy Policy.
Authentication can make or break your web app’s onboarding experience. Choosing the wrong auth solution can lead to frustrating bugs, security holes, or wasted weeks of refactoring.
In this guide, we’ll compare Auth.js and BetterAuth — two of the most popular libraries for adding authentication to your Next.js app.
| Feature | Auth.js | BetterAuth |
|---|---|---|
| Setup Time | ⏱️ Medium | ⚡ Fast |
| Documentation | 📖 Complex but thorough | 🧼 Clear & minimal |
| Flexibility | 🧩 Very high | 🔒 Opinionated |
| MFA Support | 🔌 Optional | ✅ Built-in |
| Session Strategy | 🔄 JWT/Database | 🔐 JWT only |
| Suitable for Teams? | ✅ Yes | 🤔 Limited |
| TypeScript DX | 😵💫 Inconsistent | 🧠 Strong inference |
Auth.js requires defining multiple layers of config. Here’s a basic setup:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
const handler = NextAuth({
providers: [GitHub],
callbacks: {
session: ({ session, token }) => {
session.user.id = token.sub;
return session;
},
},
});
export { handler as GET, handler as POST };Expect to write more boilerplate for TypeScript types, server/client sync, and session management.
BetterAuth ships with sensible defaults and a simplified API:
// app/api/auth/route.ts
import { authHandler } from "betterauth/server";
import GitHub from "betterauth/providers/github";
export const { GET, POST } = authHandler({
providers: [GitHub()],
features: { mfa: true },
});Cleaner syntax, no extra adapter config, and MFA support enabled by default.
Auth.js has a steeper learning curve. Docs are comprehensive, but overwhelming. You’ll often end up in Discord or GitHub issues.
BetterAuth’s DX is beginner-friendly. It uses generics and zod under the hood, so type safety “just works.”
💡 Pro tip: BetterAuth auto-inflects types from your session and provider setup — no need for a next-auth.d.ts dance.
Let's look at how both solutions handle common authentication scenarios.
First, install the necessary packages:
npm install next-auth
# or
yarn add next-authCreate an API route for authentication:
// pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export default NextAuth({
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" }
},
async authorize(credentials, req) {
// Add your authentication logic here
const user = await authenticateUser(credentials)
if (user) {
return user
}
return null
}
})
],
session: {
strategy: "jwt"
},
callbacks: {
async session({ session, token }) {
// Add custom session handling
return session
}
}
})Set up the provider in your app:
// pages/_app.tsx
import { SessionProvider } from "next-auth/react"
function MyApp({ Component, pageProps: { session, ...pageProps } }) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
)
}
export default MyAppInstall BetterAuth:
npm install better-auth
# or
yarn add better-authCreate your auth configuration:
// lib/auth.ts
import { createAuth } from "better-auth"
export const auth = createAuth({
secret: process.env.AUTH_SECRET,
database: {
type: "postgres",
url: process.env.DATABASE_URL
},
session: {
strategy: "jwt"
}
})Set up the API route:
// pages/api/auth/[...auth].ts
import { auth } from "@/lib/auth"
import { createHandler } from "better-auth/next"
export default createHandler(auth)Let's explore some common authentication scenarios and how they're handled in both libraries.
// components/LoginForm.tsx
import { signIn } from "next-auth/react"
import { useState } from "react"
export default function LoginForm() {
const [credentials, setCredentials] = useState({
email: "",
password: ""
})
const handleSubmit = async (e) => {
e.preventDefault()
try {
const result = await signIn("credentials", {
redirect: false,
email: credentials.email,
password: credentials.password
})
if (result?.error) {
// Handle error
console.error(result.error)
}
} catch (error) {
console.error("Login failed:", error)
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={credentials.email}
onChange={(e) => setCredentials({
...credentials,
email: e.target.value
})}
/>
<input
type="password"
value={credentials.password}
onChange={(e) => setCredentials({
...credentials,
password: e.target.value
})}
/>
<button type="submit">Login</button>
</form>
)
}// components/LoginForm.tsx
import { useAuth } from "better-auth/react"
import { useState } from "react"
export default function LoginForm() {
const { login } = useAuth()
const [credentials, setCredentials] = useState({
email: "",
password: ""
})
const handleSubmit = async (e) => {
e.preventDefault()
try {
await login({
email: credentials.email,
password: credentials.password
})
// Successful login will automatically update auth state
} catch (error) {
console.error("Login failed:", error)
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={credentials.email}
onChange={(e) => setCredentials({
...credentials,
email: e.target.value
})}
/>
<input
type="password"
value={credentials.password}
onChange={(e) => setCredentials({
...credentials,
password: e.target.value
})}
/>
<button type="submit">Login</button>
</form>
)
}// middleware.ts
export { default } from "next-auth/middleware"
export const config = {
matcher: ["/protected/:path*"]
}// pages/protected/dashboard.tsx
import { useSession } from "next-auth/react"
import { useRouter } from "next/router"
export default function Dashboard() {
const { data: session, status } = useSession()
const router = useRouter()
if (status === "loading") {
return <div>Loading...</div>
}
if (!session) {
router.push("/login")
return null
}
return (
<div>
<h1>Welcome {session.user.name}</h1>
{/* Dashboard content */}
</div>
)
}// middleware.ts
import { createMiddleware } from "better-auth/next"
import { auth } from "@/lib/auth"
export default createMiddleware(auth)
export const config = {
matcher: ["/protected/:path*"]
}// pages/protected/dashboard.tsx
import { useAuth } from "better-auth/react"
export default function Dashboard() {
const { user, isLoading } = useAuth()
if (isLoading) {
return <div>Loading...</div>
}
return (
<div>
<h1>Welcome {user.name}</h1>
{/* Dashboard content */}
</div>
)
}JWT Handling:
// pages/api/auth/[...nextauth].ts
export default NextAuth({
jwt: {
secret: process.env.JWT_SECRET,
maxAge: 60 * 60 * 24 * 30, // 30 days
encryption: true
},
security: {
csrf: true,
cookieSecure: process.env.NODE_ENV === "production"
}
})Custom Authorization Logic:
// lib/auth-checks.ts
export const checkUserPermissions = async (session) => {
if (!session) return false
// Add your custom authorization logic
const userRoles = await fetchUserRoles(session.user.id)
return userRoles.includes("admin")
}Built-in Rate Limiting:
// lib/auth.ts
import { createAuth } from "better-auth"
export const auth = createAuth({
rateLimit: {
enabled: true,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
},
security: {
passwordPolicy: {
minLength: 8,
requireNumbers: true,
requireSpecialChars: true
}
}
})MFA Implementation:
// pages/api/auth/enable-mfa.ts
import { auth } from "@/lib/auth"
export default async function handler(req, res) {
const { user } = await auth.getUserSession(req)
if (!user) {
return res.status(401).json({ error: "Unauthorized" })
}
const secret = await auth.mfa.generateSecret()
const qrCode = await auth.mfa.generateQRCode(secret)
return res.json({ secret, qrCode })
}If you're considering migrating from Auth.js to BetterAuth, here's a step-by-step approach:
# Export your users and sessions
pg_dump -t users -t sessions > auth_backup.sqlnpm remove next-auth
npm install better-auth// Before (Auth.js)
export default NextAuth({
providers: [...],
callbacks: {...},
session: {...}
})
// After (BetterAuth)
export const auth = createAuth({
providers: {...},
session: {...},
callbacks: {...}
})// Before (Auth.js)
import { useSession } from "next-auth/react"
// After (BetterAuth)
import { useAuth } from "better-auth/react"Let's say you're moving from Auth.js to BetterAuth.
app/api/auth/[...nextauth] to app/api/auth/route.tsuseSession from next-auth/react with useBetterSession()// hooks/useSession.ts
import { useSession } from "betterauth/client";
export function useUser() {
const { data } = useSession();
return data?.user;
}Migration usually takes 1–2 hours for a typical project.
Yes. It's used in multiple SaaS products and audited by third-party security firms.
Absolutely — it was designed with App Router in mind.
Not out of the box. You'll need to implement your own flow or use a plugin.
Go with BetterAuth if you want speed, simplicity, and strong security out of the box.
Stick with Auth.js if you need advanced customizations or already have a large app using it.
No matter your choice, both tools can serve you well in 2025 — it just depends on your project's complexity and your team's preferences.
Got questions or want a migration walkthrough? Contact us or drop a comment below!