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 Next.js 16 Setup Guide (2026)

Updated: August 12, 2026

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.

DimensionBetter Auth 1.4Auth.js v5 (NextAuth)
Weekly npm downloads (2026)~150K, climbing fast~2.5M, stable
Type safetyEnd-to-end inferred client + serverModerate; session shape via module augmentation
Email/password built-inYes, with Argon2idOnly via Credentials provider (bring your own hash)
2FA / TOTPPlugin (first-party)Not built-in
Passkeys / WebAuthnPlugin (first-party)Not built-in
Organizations / RBACPlugin (first-party)Not built-in
OAuth providers~15 first-party, growing80+ community adapters
Next.js 16 proxy.tsSupported via nextCookies()Supported via auth() export
Best fitNew SaaS, anything needing 2FA/orgsExisting 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.

# .env.local
BETTER_AUTH_SECRET=  # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp

# Optional social provider keys (add as needed)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

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.

// lib/auth.ts (Prisma variant)
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { PrismaClient } from "../src/generated/prisma"; // custom output path

const prisma = new PrismaClient();

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: "postgresql" }),
  emailAndPassword: { enabled: true },
  plugins: [nextCookies()],
});
# Prisma flow
npx @better-auth/cli generate   # updates prisma/schema.prisma
npx prisma migrate dev --name init_better_auth

Setting up email and password authentication

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:

// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
});

export const { signIn, signUp, signOut, useSession } = authClient;

Then a minimal sign-up form as a Client Component:

// app/sign-up/page.tsx
"use client";
import { useState } from "react";
import { signUp } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export default function SignUpPage() {
  const [state, setState] = useState({ name: "", email: "", password: "" });
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    const { error } = await signUp.email({
      email: state.email,
      password: state.password,
      name: state.name,
      callbackURL: "/dashboard",
    });
    if (error) return setError(error.message ?? "Sign-up failed");
    router.push("/dashboard");
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={state.name} onChange={(e) => setState({ ...state, name: e.target.value })} placeholder="Name" />
      <input value={state.email} onChange={(e) => setState({ ...state, email: e.target.value })} placeholder="Email" type="email" />
      <input value={state.password} onChange={(e) => setState({ ...state, password: e.target.value })} placeholder="Password" type="password" />
      <button type="submit">Create account</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

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:

// app/api/me/route.ts
import { auth } from "@/lib/auth";

export async function GET(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
  return Response.json({ user: session.user });
}

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.

// lib/auth.ts (with 2FA + passkeys + organizations)
import { betterAuth } from "better-auth";
import { twoFactor, passkey, organization } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg", schema }),
  emailAndPassword: { enabled: true },
  plugins: [
    twoFactor(),                    // TOTP + backup codes
    passkey({ rpName: "My App" }),  // WebAuthn / device biometrics
    organization({                  // teams with roles
      allowUserToCreateOrganization: true,
    }),
    nextCookies(),                  // KEEP LAST
  ],
});

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.

For the full plugin catalog and configuration reference, the official Next.js integration docs are the source of truth. The Drizzle adapter documentation is worth a bookmark if you're on that ORM, and the Next.js authentication guide covers the framework-level primitives Better Auth builds on.

Frequently Asked Questions

Is Better Auth free?

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.

Ben Howard
About the Author Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.