Better Auth Guide 2026: Sessions, Social, 2FA in Next.js
Ship sessions, OAuth, and 2FA in Next.js with Better Auth v1 — no vendor lock-in, no magic black boxes, just TypeScript you actually understand.
Why Better Auth in 2026
Auth is the part of every project that bites you six months later. You pick a hosted provider, everything's great, and then you hit a pricing wall, discover you can't export sessions, or find out the SDK doesn't play well with the App Router. Better Auth landed in late 2024 and by 2026 it's the library a lot of teams are quietly migrating to.
Honestly, the pitch is pretty simple: it's a TypeScript-first auth library that runs in your own infrastructure, gives you full control over the database schema, and ships adapters for Prisma, Drizzle, and raw SQL out of the box. No SaaS. No dashboard to log into. Your data, your rules.
That said, it's not zero-config magic. You actually have to read docs and wire things up. If you want a hosted solution you click through in 10 minutes, go look elsewhere. But if you want auth that you understand completely — where every session, every token, every OAuth callback is code you wrote and can debug — Better Auth is worth the setup cost.
Worth noting: Better Auth v1.0 stabilized the plugin API significantly. Before that, the 2FA and social provider plugins had some rough edges. If you tried it in 2024 and bounced, it's genuinely much better now. The breaking changes between 0.x and 1.x are real, but the migration guide is short.
Installation and Project Setup
Start with a fresh Next.js 15 app (or an existing one — doesn't matter). You need better-auth and a database adapter. This guide uses Drizzle with Postgres, but Prisma works identically.
npm install better-auth drizzle-orm @auth/drizzle-adapter
# if you're using Postgres
npm install postgresCreate your auth instance. The convention is lib/auth.ts. Everything lives here — providers, plugins, database connection, session config. Better Auth exports a betterAuth() function that returns a typed instance you'll import everywhere else.
// lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { twoFactor } from 'better-auth/plugins'
import { db } from './db'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
plugins: [
twoFactor({
issuer: 'MyApp',
}),
],
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days in seconds
updateAge: 60 * 60 * 24, // refresh if > 1 day old
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5-minute client-side cache
},
},
})
export type Session = typeof auth.$Infer.SessionThen run the migration generator. Better Auth introspects your config and outputs SQL — you don't write schema by hand. npx better-auth generate spits out a migration file you apply with your normal workflow. Quick aside: if you're using Drizzle Studio, the generated tables show up immediately and the column names are exactly what you'd expect (user, session, account, verification).
Wiring Up the Route Handler
The App Router needs a catch-all route at app/api/auth/[...all]/route.ts. This single file handles every auth endpoint — sign-in, sign-out, callbacks, token refresh, 2FA verification — by delegating to the Better Auth handler.
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth)That's it. Three lines. Better Auth exposes a handler that understands Next.js request/response objects and maps routes internally. You don't need to think about what URL does what — the client SDK handles that automatically.
On the client side, install the React integration and wrap your layout. Better Auth ships @better-auth/react which gives you useSession, signIn, signOut, and signUp hooks. The session hook uses SWR under the hood, so it revalidates on focus and handles loading states cleanly without you setting up any context manually.
// app/layout.tsx
import { BetterAuthProvider } from '@better-auth/react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<BetterAuthProvider>
{children}
</BetterAuthProvider>
</body>
</html>
)
}Social Providers: GitHub, Google, Discord
Social auth is where a lot of libraries get complicated. Better Auth keeps it boring, which is a compliment. You add providers to the socialProviders key in your auth config, supply client ID and secret from environment variables, and you're done.
// Add to your betterAuth() config in lib/auth.ts
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
},
},On the frontend, call authClient.signIn.social({ provider: 'github', callbackURL: '/dashboard' }). Better Auth handles PKCE, state verification, and account linking automatically. If someone signs in with GitHub using an email already tied to a password account, it links by default — you can toggle that behavior with accountLinking.enabled: false if you want stricter isolation.
The callback URL matters more than people realize. Set it to wherever you want the user to land post-login, not just /. You can also pass errorCallbackURL for when OAuth fails (user denies permissions, provider is down, etc.). In practice, a lot of teams forget the error URL and then wonder why failed logins just go to a blank page.
Look, OAuth redirect URIs are still the most annoying part of this whole setup. Register http://localhost:3000/api/auth/callback/github for local dev and https://yourdomain.com/api/auth/callback/github for prod in your GitHub OAuth app. Get one wrong and you'll spend 20 minutes staring at a redirect_uri_mismatch error.
Session Management and Middleware
Session handling is where Better Auth's design really pays off. Sessions are stored in your database (not a JWT you have to decode everywhere), and the library gives you a getSession() function that works in Server Components, Route Handlers, and middleware without any adapter gymnastics.
// In a Server Component or Route Handler
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
})
if (!session) {
redirect('/login')
}
return <div>Welcome, {session.user.name}</div>
}For middleware-level protection, you fetch the session at the edge. Better Auth's session lookup adds roughly 1-2 database round trips per request if you're not using the cookie cache. Enable cookieCache (shown in the setup section) to skip the DB hit on most requests — it serializes a signed, encrypted session snapshot into the cookie itself, valid for configurable minutes. You trade some staleness for speed.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
export async function middleware(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
})
const isProtected = request.nextUrl.pathname.startsWith('/dashboard')
if (isProtected && !session) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}One more thing — if you're pairing this with a nice UI, check out the glassmorphism components on Empire UI. The frosted-glass login card pattern looks sharp on auth flows and you can grab the CSS values from the glassmorphism generator in about 30 seconds.
Two-Factor Authentication with TOTP
The twoFactor plugin ships built-in. You added it to the plugins array during setup — now you just need to expose the enrollment and verification flows in your UI. Better Auth handles TOTP secret generation, QR code data, backup codes, and verification logic server-side. You supply the UI.
Enrollment is a two-step flow: generate a TOTP URI (which encodes into a QR code the user scans with their authenticator app), then verify a one-time code to confirm they set it up correctly. Better Auth gives you client methods for both.
// Enroll: get the QR code URI
const { data } = await authClient.twoFactor.getTotpUri({
password: currentPassword, // re-auth required
})
// data.totpURI → pass to a QR library like 'qrcode.react'
// Verify enrollment
await authClient.twoFactor.verifyTotp({
code: userInputCode, // 6-digit TOTP
})After enrollment, Better Auth's sign-in flow automatically detects that 2FA is enabled for the account and returns a twoFactorRedirect field instead of a session. You check for that field in the sign-in response and show your 2FA input screen. Then call authClient.twoFactor.verifyTotp() with the user's code and you get a real session back.
const result = await authClient.signIn.email({
email,
password,
})
if (result.data?.twoFactorRedirect) {
// Show your 2FA input UI instead of redirecting
setStep('2fa')
} else {
router.push('/dashboard')
}Backup codes are generated automatically when 2FA is enrolled — 10 codes by default. Store them UX warning front-and-center before the user leaves the enrollment screen. Better Auth tracks which ones are used, so single-use is enforced automatically.
Protecting Routes and Checking Roles
Role-based access is a plugin too (@better-auth/plugin-access), but for most apps you can get pretty far with custom fields. Better Auth lets you extend the user schema with additional columns via user.additionalFields. Add a role field and it gets stored in the user table and returned in the session object.
// In betterAuth() config
user: {
additionalFields: {
role: {
type: 'string',
defaultValue: 'user',
input: false, // not settable from client signUp
},
},
},Now session.user.role is typed and available everywhere you call getSession(). You can check it in Server Components, API routes, and middleware. For a more sophisticated RBAC system with permissions and resource-level checks, the access plugin adds a declarative permission model on top — but most SaaS apps don't need that on day one.
In practice, the pattern that works cleanest is: middleware for route-level protection (authenticated vs not), Server Component checks for role-level UI branching, and Route Handler checks for API authorization. Don't try to put everything in middleware — it gets messy fast and Next.js middleware has a 1MB size limit that you'll eventually hit if you import too many things.
You've now got a full auth stack: email/password, social OAuth, database sessions, 2FA, and role fields — without a single third-party SaaS in the critical path. Browse the Empire UI component library to find sign-in form components and dashboard shells that pair well with this stack. The gradient generator is also handy for making your auth pages look less generic.
FAQ
Yes, and it's one of the better-designed libraries for it. The auth.api.getSession() method accepts standard Headers objects, so it works in Server Components, Route Handlers, and middleware without any wrapper. You pass await headers() from next/headers and it just works.
By default, sessions are stored in your database with an opaque token in a cookie. This means you can revoke them instantly by deleting the row. JWTs can't be revoked without extra infrastructure. The cookie cache option gives you JWT-like performance on reads without losing revocation control.
Yes. Swap drizzleAdapter for prismaAdapter from better-auth/adapters/prisma and pass your Prisma client. The schema migration generator also outputs Prisma schema additions when it detects a Prisma project. Everything else in the config is adapter-agnostic.
NextAuth v5 / Auth.js has broader ecosystem adoption and more providers, but its session model and TypeScript types have historically been awkward in the App Router. Better Auth was designed for the App Router from the start, has a cleaner plugin API, and gives you more control over the database schema. Auth.js is fine — Better Auth is just more explicit.