Better Auth with Next.js 16: Email/Password, Social Login, and Session Middleware (2026)
Learn how to install Better Auth in Next.js 16, wire up Drizzle or Prisma, add email/password plus Google and GitHub social login, and lock down routes with proxy.ts and Server Action session checks.
Better Auth is a TypeScript-first, self-hosted authentication library for Next.js that ships email/password, OAuth, sessions, 2FA, and passkeys behind one framework-agnostic API, and in 2026 it's become the default recommendation for new Next.js projects. This guide walks through installing Better Auth in a Next.js 16 App Router project, wiring it up to Drizzle or Prisma, adding email/password plus Google and GitHub social login, and protecting routes with the new proxy.ts. Every snippet below is runnable as-is against a fresh create-next-app.
Better Auth 1.4+ works natively with Next.js 16 App Router when you install the nextCookies() plugin, which handles Server Action cookie writes automatically.
The Better Auth CLI generates schema for Drizzle, Prisma, or raw SQL, so you never hand-write the user, session, account, and verification tables.
In Next.js 16, route protection lives in proxy.ts (not middleware.ts); use a cheap cookie check there and re-verify sessions in Server Components, Route Handlers, and Server Actions.
Middleware alone is not security. Server Actions are POST requests to page URLs that bypass matcher rules, so every sensitive action needs its own auth.api.getSession() call.
Better Auth surpassed 150,000 weekly npm downloads and 26,000 GitHub stars in early 2026 after a $5M seed round. Auth.js still leads on installed base but has fewer built-in features.
Enabling 2FA, passkeys, organizations, or RBAC is a one-line plugin add. No fork, no separate library, no custom database columns to invent.
What is Better Auth, and why it's winning in 2026
Better Auth is an open-source, TypeScript-first authentication framework that runs entirely on your own server and database. Its core exports a single betterAuth() factory that returns both an HTTP handler (mount it at /api/auth/[...all]) and a fully typed server API (auth.api.signInEmail(), auth.api.getSession(), and so on). No hosted service, no per-MAU billing, no vendor lock-in. You own the database rows.
The library launched in September 2024 and, as of early 2026, has crossed 150,000 weekly downloads on npm, picked up 26,000+ GitHub stars, and raised a $5M seed round. More importantly, the Auth.js team itself now points new projects at Better Auth for anything beyond the simplest OAuth login. I've been shipping Next.js apps since the pages/-only days, and honestly, the last time I felt this good adopting an auth library was the original NextAuth v3 announcement. It's that different.
Where Better Auth pulls ahead is the batteries-included plugin system. Email/password with Argon2id hashing, magic links, passkeys (WebAuthn), TOTP-based 2FA, backup codes, organizations with roles, admin impersonation, rate limiting, and password policies are all first-party plugins. You import them, add them to the plugins array, and they get their own database tables via the CLI. Compare that to Auth.js v5, where 2FA and passkeys still require third-party adapters or hand-rolled columns, and the win is obvious.
Better Auth vs Auth.js: a quick comparison
Here's the honest head-to-head. If you're greenfield in 2026, Better Auth is the answer for almost every case. If you already have a working Auth.js v5 install with a handful of OAuth providers and no plans to add 2FA or organizations, don't migrate for the sake of migration. The Auth.js team is still shipping bug fixes.
Dimension
Better Auth 1.4
Auth.js v5 (NextAuth)
Weekly npm downloads (2026)
~150K, climbing fast
~2.5M, stable
Type safety
End-to-end inferred client + server
Moderate; session shape via module augmentation
Email/password built-in
Yes, with Argon2id
Only via Credentials provider (bring your own hash)
2FA / TOTP
Plugin (first-party)
Not built-in
Passkeys / WebAuthn
Plugin (first-party)
Not built-in
Organizations / RBAC
Plugin (first-party)
Not built-in
OAuth providers
~15 first-party, growing
80+ community adapters
Next.js 16 proxy.ts
Supported via nextCookies()
Supported via auth() export
Best fit
New SaaS, anything needing 2FA/orgs
Existing Auth.js apps, simple OAuth-only
For a deeper walk-through of the current Auth.js v5 setup (sessions, providers, and route protection), see my earlier Auth.js v5 complete guide. The two guides intentionally mirror each other section by section so you can compare setup ergonomics side by side.
Installing Better Auth in a Next.js 16 project
Spin up a fresh app or drop into an existing one. Better Auth works with Next.js 14, 15, and 16, but this guide targets 16 because proxy.ts is now stable and the Node.js runtime in middleware is no longer experimental.
# from a fresh scaffold
npx create-next-app@latest my-app --typescript --app --tailwind
cd my-app
# install Better Auth
npm install better-auth
Create .env.local with the two required variables plus your database URL. The secret signs and encrypts session cookies, so treat it exactly like a database password and generate a new one per environment.
Next, mount the auth handler at app/api/auth/[...all]/route.ts. This one file catches every Better Auth endpoint (sign-in, sign-out, callback, verification, OAuth) and delegates to the server config we're about to write.
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
Configuring the database adapter for Drizzle or Prisma
Better Auth needs four core tables (user, session, account, and verification), plus extra tables per plugin. 2FA adds one, organizations add three, and so on. You never write those tables by hand; the CLI generates them.
Here's the Drizzle path. If you're already using Drizzle in this project, this integrates cleanly with your existing db.ts. For a full setup walkthrough that includes migrations and connection pooling, see my Drizzle ORM with Next.js guide.
// lib/auth.ts (Drizzle + PostgreSQL)
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "@/lib/db";
import * as schema from "@/lib/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema, // pass generated schema after running the CLI
}),
emailAndPassword: { enabled: true },
plugins: [nextCookies()], // MUST be last in the plugins array
});
Now generate the schema:
# writes lib/schema.ts with the four core tables
npx @better-auth/cli generate
# then run your normal Drizzle migration
npx drizzle-kit generate
npx drizzle-kit migrate
Prefer Prisma? The adapter shape is identical. Note that from Prisma 7 onward, the output field in schema.prisma is required, and you must import PrismaClient from that path, not from @prisma/client.
With emailAndPassword: { enabled: true } already in your config, credential auth is live. Passwords hash with Argon2id (the OWASP-recommended default in 2026), and Better Auth enforces a minimum length of 8 characters by default (configurable via minPasswordLength).
Create a client instance so React components can call the API with full type inference:
Sign-in follows the same pattern with signIn.email({ email, password }). If you'd rather drive auth from a Server Action (fewer client bundles, works without JS), call auth.api.signInEmail() directly on the server. The nextCookies() plugin ensures the resulting session cookie is set on the outgoing response.
Adding social providers: Google and GitHub
Google and GitHub are the two providers I add first on every real project. Register OAuth apps in each provider's console, set the redirect URI to {BETTER_AUTH_URL}/api/auth/callback/{provider}, then extend the auth config:
// lib/auth.ts (add socialProviders block)
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema }),
emailAndPassword: { enabled: true },
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!,
// Ask Google for a refresh token so long-lived sessions still work
accessType: "offline",
prompt: "select_account+consent",
},
},
plugins: [nextCookies()],
});
A single button triggers the OAuth flow from the client:
"use client";
import { signIn } from "@/lib/auth-client";
export function GithubButton() {
return (
<button
onClick={() => signIn.social({ provider: "github", callbackURL: "/dashboard" })}
>
Continue with GitHub
</button>
);
}
Better Auth also supports Apple, Discord, Microsoft, Facebook, LinkedIn, Twitch, Twitter/X, Dropbox, Reddit, Spotify, GitLab, Kick, and Zoom out of the box, and you can register any OIDC-compliant provider with the genericOAuth plugin if yours isn't on that list.
How do you protect routes with Better Auth in Next.js 16?
You protect routes with a proxy.ts file at the project root (formerly middleware.ts), plus explicit session checks inside every Server Component, Route Handler, and Server Action that touches user data. Middleware alone is a first-line guard; it is not sufficient by itself. This is the part where my old pages-router habits collided hardest with the App Router — in the pages router, wrapping getServerSideProps covered the page and its API twin. In App Router land, Server Actions and Route Handlers each need their own check.
If you're new to the file rename, my middleware to proxy.ts migration guide covers what the codemod misses. For Better Auth specifically, use a cheap cookie check at the edge, and don't call auth.api.getSession() in the proxy because that does a full database lookup on every request:
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED = ["/dashboard", "/settings", "/api/private"];
export function proxy(request: NextRequest) {
const isProtected = PROTECTED.some((path) =>
request.nextUrl.pathname.startsWith(path),
);
if (!isProtected) return NextResponse.next();
// Better Auth stores the session in this cookie by default
const sessionCookie = request.cookies.get("better-auth.session_token");
if (!sessionCookie) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("from", request.nextUrl.pathname);
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/api/private/:path*"],
};
The cookie check is fast (no DB round-trip) but it only proves a session cookie exists, not that it's still valid. That's fine for the redirect UX, because attackers who craft a fake cookie get bounced by the real session check inside the page.
Session checks in Server Components, Route Handlers, and Server Actions
Inside a Server Component, auth.api.getSession() takes the request headers and returns the full user + session object, or null:
// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in");
return <h1>Welcome, {session.user.name}</h1>;
}
Route Handlers work the same way. You receive the Request object and forward its headers:
Server Actions are the easiest to forget. Because a Server Action is a POST to the same URL as the page it lives on, proxy.ts matcher rules don't distinguish it from the GET that renders the page. A malicious client can construct the POST body and hit the action directly. So every Server Action that mutates data starts with a session check:
// app/settings/actions.ts
"use server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function updateProfile(formData: FormData) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const name = String(formData.get("name") ?? "");
await db.update(usersTable)
.set({ name })
.where(eq(usersTable.id, session.user.id));
revalidatePath("/settings");
}
If you're rate-limiting these endpoints (and you should for anything auth-related), the patterns in my Next.js rate limiting guide apply directly to Better Auth handlers as well.
Beyond basics: 2FA, passkeys, and organizations
This is where Better Auth stops feeling like an auth library and starts feeling like a whole IAM layer. Each feature is a plugin — import it, add it to plugins, re-run npx @better-auth/cli generate to add the new tables, and you're done.
The twoFactor plugin gives you server APIs like auth.api.enableTwoFactor() and auth.api.verifyTOTP() plus a matching client with authClient.twoFactor.enable(). It generates the QR code URL for you; hand that to a client-side QR renderer (e.g. qrcode.react) and you have Google Authenticator support in under an hour.
Passkeys are the underrated win, honestly. Once the plugin is on, authClient.passkey.addPasskey() registers a device (Face ID, Touch ID, Windows Hello, YubiKey) and authClient.signIn.passkey() lets users sign in with a single biometric prompt. No password to remember, no phishing surface, no SMS bill.
Organizations turn Better Auth into a multi-tenant back end: workspaces, invites, roles (owner/admin/member by default, customizable), and per-organization sessions. This maps neatly onto the tenant-aware patterns from the Next.js multi-tenant SaaS guide.
Yes. Better Auth is MIT-licensed and self-hosted, so there are no per-user fees. Your only costs are the database and server you run it on, and for most Next.js projects that's a shared Postgres instance and effectively $0 marginal cost.
Can Better Auth replace NextAuth (Auth.js) in an existing app?
Yes, but plan for a data migration. The user and account tables differ in shape, so you'll need a script to map next_auth_accounts rows into Better Auth's account table. If your only auth is a single OAuth provider with no active sessions to preserve, the switch is a few hours; if you have millions of users and a live login flow, plan a dual-write period.
Does Better Auth support magic links?
Yes, via the magicLink() plugin. Add it to the plugins array, provide a sendMagicLink function that emails the token, and users can sign in without a password by clicking a link. Works nicely alongside email/password so users can choose either flow.
How does Better Auth handle sessions under the hood?
By default it stores an opaque session token in a signed, HTTP-only cookie (better-auth.session_token) and looks the token up against the session table on each request. You can opt into JWT-based sessions via the jwt plugin if you need stateless verification at the edge, but the DB-backed default is safer because you can revoke individual sessions instantly.
Do I still need CSRF protection with Better Auth?
Better Auth ships with origin checks on all its endpoints, and Next.js Server Actions have built-in CSRF protection via origin validation as of Next.js 14+. For custom Route Handlers that mutate data, you should still verify the request origin or use SameSite=Lax cookies (Better Auth's default). Don't turn origin checking off.
Prefetch on the server, hydrate on the client. A 2026 guide to using TanStack Query v5 with the Next.js 16 App Router: HydrationBoundary, streaming, Server Actions, and the common hydration errors that break RSC setups.
Real effort estimates and a phase-by-phase playbook for migrating from Gatsby to Next.js 16 App Router: GraphQL to Server Components, plugin mapping, dynamic routes, redirects, and deployment.
How to wire up useReportWebVitals in the Next.js App Router, ship LCP, INP, and CLS from real users to any analytics endpoint, and debug the metrics that hurt Search rankings, with working code.