Better Auth with Next.js 15: The Complete Authentication Guide for 2026

Authentication made right. Better Auth is the framework-agnostic, TypeScript-first authentication library that has taken the ecosystem by storm in 2026. It handles sessions, OAuth, email verification, two-factor auth, and more — with zero lock-in and full type safety. In this tutorial, you will build a complete auth system with Next.js 15.
What You Will Learn
By the end of this tutorial, you will:
- Set up Better Auth in a Next.js 15 App Router project
- Implement email/password registration and login
- Add GitHub and Google OAuth providers
- Protect routes with middleware and server-side session checks
- Build a role-based access control (RBAC) system
- Handle email verification and password reset flows
- Manage user sessions with secure cookie-based tokens
- Deploy a production-ready authentication system
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, generics, async/await)
- Next.js 15 familiarity (App Router, Server Components, Server Actions)
- PostgreSQL running locally or a cloud database (Neon, Supabase, or similar)
- A code editor — VS Code or Cursor recommended
- GitHub and Google OAuth apps created (we will walk through this)
Why Better Auth?
The JavaScript authentication landscape has evolved significantly. Lucia Auth was deprecated, NextAuth (Auth.js) has its complexities, and many developers wanted something simpler yet more powerful. Better Auth fills that gap:
| Feature | Better Auth | Auth.js v5 | Custom JWT |
|---|---|---|---|
| Type Safety | Full TypeScript inference | Partial | Manual |
| Framework Lock-in | None | Next.js focused | None |
| Database Control | You own it | Adapter-based | You own it |
| OAuth Providers | 20+ built-in | 80+ built-in | Manual |
| 2FA / MFA | Built-in plugin | Community | Manual |
| RBAC | Built-in plugin | Manual | Manual |
| Email Verification | Built-in | Built-in | Manual |
| Session Strategy | Cookie + DB | JWT or DB | JWT |
| Bundle Size | ~15KB | ~30KB | ~2KB |
| Learning Curve | Low | Medium | High |
Better Auth gives you full control over your database, zero vendor lock-in, and a plugin architecture that lets you add features incrementally.
Step 1: Create a Next.js Project
Start by creating a fresh Next.js 15 application:
npx create-next-app@latest better-auth-demo --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd better-auth-demoInstall Better Auth and its dependencies:
npm install better-auth
npm install -D @types/better-sqlite3For this tutorial, we will use PostgreSQL with Drizzle ORM. Install the database dependencies:
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kitStep 2: Configure the Database
Create a .env.local file in the project root:
DATABASE_URL="postgresql://user:password@localhost:5432/better_auth_demo"
BETTER_AUTH_SECRET="your-secret-key-at-least-32-characters-long"
BETTER_AUTH_URL="http://localhost:3000"
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"Generate a secure secret:
openssl rand -base64 32Create the database configuration at src/db/index.ts:
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);Step 3: Set Up Better Auth Server
Create the core auth configuration at src/lib/auth.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // Set true in production
},
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!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update session every 24 hours
cookieCache: {
enabled: true,
maxAge: 5 * 60, // Cache for 5 minutes
},
},
});Step 4: Create the API Route Handler
Better Auth needs an API route to handle all auth-related requests. Create src/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 is it — Better Auth now handles all routes under /api/auth/* automatically, including:
POST /api/auth/sign-up/email— Register with email/passwordPOST /api/auth/sign-in/email— Login with email/passwordGET /api/auth/sign-in/social— OAuth redirectPOST /api/auth/sign-out— Sign outGET /api/auth/session— Get current session
Step 5: Generate Database Tables
Better Auth can generate the database schema for you. Run:
npx better-auth generateThis creates a migration file with the required tables: user, session, account, and verification. Apply the migration:
npx drizzle-kit pushThe generated schema includes:
CREATE TABLE "user" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT UNIQUE NOT NULL,
"emailVerified" BOOLEAN DEFAULT FALSE,
"image" TEXT,
"createdAt" TIMESTAMP DEFAULT NOW(),
"updatedAt" TIMESTAMP DEFAULT NOW()
);
CREATE TABLE "session" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL REFERENCES "user"("id"),
"token" TEXT UNIQUE NOT NULL,
"expiresAt" TIMESTAMP NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT
);
CREATE TABLE "account" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL REFERENCES "user"("id"),
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"expiresAt" TIMESTAMP,
"password" TEXT
);
CREATE TABLE "verification" (
"id" TEXT PRIMARY KEY,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP NOT NULL
);Step 6: Create the Auth Client
Create a client-side auth helper at src/lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});
export const {
signIn,
signUp,
signOut,
useSession,
} = authClient;Add NEXT_PUBLIC_APP_URL to your .env.local:
NEXT_PUBLIC_APP_URL="http://localhost:3000"Step 7: Build the Sign-Up Page
Create a registration form at src/app/auth/sign-up/page.tsx:
"use client";
import { useState } from "react";
import { signUp } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function SignUpPage() {
const [name, setName] = useState("");
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();
setError("");
setLoading(true);
const { error } = await signUp.email({
email,
password,
name,
});
if (error) {
setError(error.message || "Something went wrong");
setLoading(false);
return;
}
router.push("/dashboard");
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Create Account</h2>
<p className="mt-2 text-gray-600">Start your journey today</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="John Doe"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="john@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="At least 8 characters"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
>
{loading ? "Creating account..." : "Sign Up"}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => signIn.social({ provider: "github" })}
className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
GitHub
</button>
<button
onClick={() => signIn.social({ provider: "google" })}
className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
Google
</button>
</div>
<p className="text-center text-sm text-gray-600">
Already have an account?{" "}
<Link href="/auth/sign-in" className="text-blue-600 hover:underline">
Sign in
</Link>
</p>
</div>
</div>
);
}Step 8: Build the Sign-In Page
Create the login form at src/app/auth/sign-in/page.tsx:
"use client";
import { useState } from "react";
import { signIn } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import Link from "next/link";
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();
setError("");
setLoading(true);
const { error } = await signIn.email({
email,
password,
});
if (error) {
setError(error.message || "Invalid credentials");
setLoading(false);
return;
}
router.push("/dashboard");
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Welcome Back</h2>
<p className="mt-2 text-gray-600">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center">
<input type="checkbox" className="rounded border-gray-300" />
<span className="ml-2 text-sm text-gray-600">Remember me</span>
</label>
<Link href="/auth/forgot-password" className="text-sm text-blue-600 hover:underline">
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => signIn.social({ provider: "github" })}
className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
GitHub
</button>
<button
onClick={() => signIn.social({ provider: "google" })}
className="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
Google
</button>
</div>
<p className="text-center text-sm text-gray-600">
Do not have an account?{" "}
<Link href="/auth/sign-up" className="text-blue-600 hover:underline">
Sign up
</Link>
</p>
</div>
</div>
);
}Step 9: Protect Routes with Middleware
Create src/middleware.ts to protect routes at the edge:
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
const protectedRoutes = ["/dashboard", "/settings", "/admin"];
const authRoutes = ["/auth/sign-in", "/auth/sign-up"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtectedRoute = protectedRoutes.some((route) =>
pathname.startsWith(route)
);
const isAuthRoute = authRoutes.some((route) =>
pathname.startsWith(route)
);
const session = await auth.api.getSession({
headers: await headers(),
});
// Redirect unauthenticated users to sign-in
if (isProtectedRoute && !session) {
const signInUrl = new URL("/auth/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
// Redirect authenticated users away from auth pages
if (isAuthRoute && session) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*", "/auth/:path*"],
};Step 10: Build the Dashboard with Session Data
Create a protected dashboard at src/app/dashboard/page.tsx:
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { SignOutButton } from "@/components/sign-out-button";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/auth/sign-in");
}
const { user } = session;
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<h1 className="text-xl font-semibold">Dashboard</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user.email}</span>
<SignOutButton />
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white rounded-xl shadow-sm p-8">
<h2 className="text-2xl font-bold mb-6">
Welcome, {user.name}!
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="p-6 bg-blue-50 rounded-lg">
<h3 className="font-semibold text-blue-900">Profile</h3>
<p className="text-sm text-blue-700 mt-1">{user.email}</p>
<p className="text-xs text-blue-600 mt-2">
Verified: {user.emailVerified ? "Yes" : "No"}
</p>
</div>
<div className="p-6 bg-green-50 rounded-lg">
<h3 className="font-semibold text-green-900">Session</h3>
<p className="text-sm text-green-700 mt-1">Active</p>
<p className="text-xs text-green-600 mt-2">
Expires: {new Date(session.session.expiresAt).toLocaleDateString()}
</p>
</div>
<div className="p-6 bg-purple-50 rounded-lg">
<h3 className="font-semibold text-purple-900">Account</h3>
<p className="text-sm text-purple-700 mt-1">
ID: {user.id.slice(0, 8)}...
</p>
<p className="text-xs text-purple-600 mt-2">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
</main>
</div>
);
}Create the sign-out button component at src/components/sign-out-button.tsx:
"use client";
import { signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export function SignOutButton() {
const router = useRouter();
return (
<button
onClick={async () => {
await signOut();
router.push("/auth/sign-in");
}}
className="px-4 py-2 text-sm bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition"
>
Sign Out
</button>
);
}Step 11: Add Role-Based Access Control
Better Auth has a built-in RBAC plugin. Update src/lib/auth.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin } from "better-auth/plugins";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
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: [
admin(), // Adds role and banned fields to user
],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
});Update the auth client to include the admin plugin at src/lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
import { adminClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
plugins: [adminClient()],
});
export const { signIn, signUp, signOut, useSession } = authClient;Now you can check roles in your components:
// Server Component
const session = await auth.api.getSession({
headers: await headers(),
});
if (session?.user.role !== "admin") {
redirect("/dashboard");
}
// Client Component
const { data: session } = useSession();
if (session?.user.role === "admin") {
// Show admin controls
}Create an admin page at src/app/admin/page.tsx:
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function AdminPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session || session.user.role !== "admin") {
redirect("/dashboard");
}
// List all users using the admin API
const users = await auth.api.listUsers({
headers: await headers(),
});
return (
<div className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold mb-8">Admin Panel</h1>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Joined
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users?.users?.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 text-sm">{user.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
<td className="px-6 py-4">
<span className={`text-xs px-2 py-1 rounded-full ${
user.role === "admin"
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-800"
}`}>
{user.role || "user"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(user.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}Step 12: Add Email Verification
Update the auth configuration to enable email verification:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin } from "better-auth/plugins";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendVerificationEmail: async ({ user, url }) => {
// In production, use a service like Resend, SendGrid, or AWS SES
console.log(`Verification email for ${user.email}: ${url}`);
// Example with Resend:
// await resend.emails.send({
// from: "auth@yourdomain.com",
// to: user.email,
// subject: "Verify your email",
// html: `<a href="${url}">Verify Email</a>`,
// });
},
sendResetPassword: async ({ user, url }) => {
console.log(`Password reset for ${user.email}: ${url}`);
},
},
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: [admin()],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
});Step 13: Build the Forgot Password Flow
Create src/app/auth/forgot-password/page.tsx:
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
await authClient.forgetPassword({
email,
redirectTo: "/auth/reset-password",
});
setSent(true);
setLoading(false);
};
if (sent) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full p-8 bg-white rounded-xl shadow-lg text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Check Your Email</h2>
<p className="text-gray-600 mb-6">
If an account exists with {email}, you will receive a password reset link.
</p>
<Link href="/auth/sign-in" className="text-blue-600 hover:underline">
Back to Sign In
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Forgot Password</h2>
<p className="mt-2 text-gray-600">
Enter your email and we will send you a reset link
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
>
{loading ? "Sending..." : "Send Reset Link"}
</button>
</form>
<p className="text-center text-sm text-gray-600">
<Link href="/auth/sign-in" className="text-blue-600 hover:underline">
Back to Sign In
</Link>
</p>
</div>
</div>
);
}Step 14: Add a useSession Hook for Client Components
Better Auth provides a React hook out of the box. Here is how to use it in any client component:
"use client";
import { useSession } from "@/lib/auth-client";
export function UserAvatar() {
const { data: session, isPending } = useSession();
if (isPending) {
return <div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />;
}
if (!session) {
return null;
}
return (
<div className="flex items-center gap-2">
{session.user.image ? (
<img
src={session.user.image}
alt={session.user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm font-bold">
{session.user.name.charAt(0).toUpperCase()}
</div>
)}
<span className="text-sm font-medium">{session.user.name}</span>
</div>
);
}Testing Your Implementation
Start the development server:
npm run devTest the following flows:
- Sign Up: Navigate to
/auth/sign-up, create an account with email and password - Sign In: Navigate to
/auth/sign-in, log in with your credentials - Dashboard: Verify you see your user data on
/dashboard - Sign Out: Click the sign-out button, verify redirect to sign-in
- OAuth: Test GitHub and Google sign-in buttons
- Protected Routes: Try accessing
/dashboardwhile signed out — should redirect - Auth Routes: Try accessing
/auth/sign-inwhile signed in — should redirect to dashboard
Troubleshooting
"Invalid session" errors
Make sure BETTER_AUTH_SECRET is set and consistent across restarts. If you change it, all existing sessions become invalid.
OAuth callback errors
Verify your callback URLs in GitHub/Google OAuth settings match:
- GitHub:
http://localhost:3000/api/auth/callback/github - Google:
http://localhost:3000/api/auth/callback/google
Database connection issues
Ensure your DATABASE_URL is correct and the database is accessible. Run npx drizzle-kit push to ensure tables exist.
Session not persisting
Check that cookies are being set correctly. Better Auth uses __Secure- prefixed cookies in production — make sure your domain supports HTTPS.
Production Checklist
Before deploying to production:
- Set a strong
BETTER_AUTH_SECRET(at least 32 characters) - Enable
requireEmailVerification - Configure a real email provider (Resend, SendGrid)
- Set
BETTER_AUTH_URLto your production domain - Enable HTTPS (required for secure cookies)
- Configure rate limiting on auth endpoints
- Set up proper CORS headers if using a separate frontend
- Test all OAuth callback URLs with production domains
- Enable session cookie cache for performance
Next Steps
Now that you have a solid authentication system, consider:
- Two-Factor Authentication: Add the
twoFactorplugin for TOTP-based 2FA - Magic Links: Enable passwordless authentication with email magic links
- Organization Support: Use the
organizationplugin for multi-tenant apps - Rate Limiting: Add the
rateLimitplugin to prevent brute-force attacks - Passkeys: Enable WebAuthn passkey authentication for passwordless login
Conclusion
Better Auth provides a powerful, type-safe, and flexible authentication solution for Next.js applications. Unlike opinionated solutions, it gives you full control over your database schema, session management, and user experience while handling the complex security details for you.
In this tutorial, you built a complete authentication system with email/password, OAuth, middleware protection, RBAC, and email verification. The plugin architecture means you can progressively add features like 2FA, magic links, and organizations as your application grows.
Better Auth has quickly become the go-to choice in 2026 for developers who want authentication that is both simple to set up and powerful enough for production — without the vendor lock-in.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a SaaS Starter Kit with Next.js 15, Stripe Subscriptions, and Auth.js v5
Learn how to build a production-ready SaaS application with Next.js 15, Stripe for subscription billing, and Auth.js v5 for authentication. This step-by-step tutorial covers project setup, OAuth login, pricing plans, webhook handling, and protected routes.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.