Better Auth with Next.js 16: The Complete Authentication Guide
Authentication in Next.js has always been a pain point. NextAuth (now Auth.js) works but feels overly complex for simple use cases. Rolling your own auth is a security risk. And third-party services like Auth0 add cost and vendor lock-in.
Better Auth changes the equation. It's a TypeScript-first authentication framework that's genuinely easy to set up, fully type-safe, and gives you complete control over your auth logic. Combined with Next.js 16's new features, you get a modern auth stack that just works.
Why Better Auth?
Before diving into the setup, here's why Better Auth is worth your attention:
5-minute setup — Not marketing speak, it's actually that fast
Full TypeScript support — Real type safety, not just type definitions
Framework-agnostic — Works with React, Vue, Svelte, and more
Plugin ecosystem — Two-factor auth, organizations, and advanced features built-in
You own it — No vendor lock-in, runs on your infrastructure
The library has 24k+ GitHub stars and endorsements from Guillermo Rauch (Vercel's CEO). It's production-ready.
Installation
Start by installing Better Auth and its peer dependencies:
npm install better-authYou'll also need a database adapter. Better Auth supports Prisma, Drizzle, and raw SQL. For this guide, we'll use Prisma:
npm install @prisma/client prisma
npx prisma initDatabase Setup
Add the required auth tables to your Prisma schema:
// prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Account {
id String @id @default(cuid())
userId String
provider String
providerAccountId String
accessToken String?
refreshToken String?
expiresAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Verification {
id String @id @default(cuid())
identifier String
token String
expiresAt DateTime
@@unique([identifier, token])
}Run the migration:
npx prisma migrate dev --name add-auth-tablesAuth Configuration
Create your auth configuration file:
// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql", // or "mysql", "sqlite"
}),
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!,
},
},
plugins: [
nextCookies(), // Handles cookies in Server Actions automatically
],
});
export type Session = typeof auth.$Infer.Session;The nextCookies plugin is essential for Next.js. It automatically handles cookie setting in Server Actions and Server Components.
API Route Handler
Create the catch-all route that handles all auth endpoints:
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);This single file handles sign-in, sign-up, sign-out, OAuth callbacks, and session management.
Client Setup
Create the auth client for use in React components:
// 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;Building the Sign-In Page
Here's a complete sign-in component:
// app/sign-in/page.tsx
"use client";
import { signIn } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function SignInPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
const { error } = await signIn.email({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
router.push("/dashboard");
};
const handleGitHubSignIn = async () => {
await signIn.social({
provider: "github",
callbackURL: "/dashboard",
});
};
return (
<div className="max-w-md mx-auto mt-20 p-6">
<h1 className="text-2xl font-bold mb-6">Sign In</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 border rounded"
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 border rounded"
required
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full p-3 bg-blue-600 text-white rounded"
>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
<div className="mt-6">
<button
onClick={handleGitHubSignIn}
className="w-full p-3 bg-gray-800 text-white rounded"
>
Continue with GitHub
</button>
</div>
</div>
);
}Server-Side Session Access
Access sessions in Server Components without any client-side JavaScript:
// 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 (
<div className="p-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-4">Welcome, {session.user.name || session.user.email}</p>
</div>
);
}Route Protection with Next.js 16 Proxy
Next.js 16 replaces middleware.ts with proxy.ts. Here's how to protect routes:
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function proxy(request: NextRequest) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};For faster checks without database calls, use cookie validation:
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function proxy(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};Adding Two-Factor Authentication
Better Auth makes 2FA straightforward with the twoFactor plugin:
// lib/auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
// ... existing config
plugins: [
nextCookies(),
twoFactor({
issuer: "YourApp",
}),
],
});Then use it in your components:
// Enable 2FA for a user
const { data: totpURI } = await authClient.twoFactor.enable();
// Verify during sign-in
await signIn.email({
email,
password,
twoFactorCode: userInputCode,
});Environment Variables
Add these to your .env.local:
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Database
DATABASE_URL="postgresql://..."
# OAuth Providers
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Auth Secret (generate with: openssl rand -base64 32)
BETTER_AUTH_SECRET=your_random_secret_keyCommon Patterns
Sign Out Button
"use client";
import { signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export function SignOutButton() {
const router = useRouter();
const handleSignOut = async () => {
await signOut();
router.push("/");
};
return (
<button onClick={handleSignOut}>
Sign Out
</button>
);
}Session Hook in Client Components
"use client";
import { useSession } from "@/lib/auth-client";
export function UserMenu() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <a href="/sign-in">Sign In</a>;
return <span>Hello, {session.user.name}</span>;
}What to Watch For
Migration from middleware: If upgrading from Next.js 15, rename
middleware.tstoproxy.tsand the function frommiddlewaretoproxyCookie configuration: In production, ensure your domain and secure settings are correct
Database connections: Use connection pooling in serverless environments
Wrapping Up
Better Auth with Next.js 16 is a solid combination. You get type-safe authentication that's easy to understand, extend, and maintain. The new proxy system in Next.js 16 pairs well with Better Auth's session handling, and features like 2FA and organizations are just a plugin away.
Start with email/password auth, add OAuth providers as needed, and you'll have production-ready authentication running in under an hour.