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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

FeatureBetter AuthAuth.js v5Custom JWT
Type SafetyFull TypeScript inferencePartialManual
Framework Lock-inNoneNext.js focusedNone
Database ControlYou own itAdapter-basedYou own it
OAuth Providers20+ built-in80+ built-inManual
2FA / MFABuilt-in pluginCommunityManual
RBACBuilt-in pluginManualManual
Email VerificationBuilt-inBuilt-inManual
Session StrategyCookie + DBJWT or DBJWT
Bundle Size~15KB~30KB~2KB
Learning CurveLowMediumHigh

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-demo

Install Better Auth and its dependencies:

npm install better-auth
npm install -D @types/better-sqlite3

For this tutorial, we will use PostgreSQL with Drizzle ORM. Install the database dependencies:

npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit

Step 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 32

Create 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/password
  • POST /api/auth/sign-in/email — Login with email/password
  • GET /api/auth/sign-in/social — OAuth redirect
  • POST /api/auth/sign-out — Sign out
  • GET /api/auth/session — Get current session

Step 5: Generate Database Tables

Better Auth can generate the database schema for you. Run:

npx better-auth generate

This creates a migration file with the required tables: user, session, account, and verification. Apply the migration:

npx drizzle-kit push

The 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 dev

Test the following flows:

  1. Sign Up: Navigate to /auth/sign-up, create an account with email and password
  2. Sign In: Navigate to /auth/sign-in, log in with your credentials
  3. Dashboard: Verify you see your user data on /dashboard
  4. Sign Out: Click the sign-out button, verify redirect to sign-in
  5. OAuth: Test GitHub and Google sign-in buttons
  6. Protected Routes: Try accessing /dashboard while signed out — should redirect
  7. Auth Routes: Try accessing /auth/sign-in while 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_URL to 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 twoFactor plugin for TOTP-based 2FA
  • Magic Links: Enable passwordless authentication with email magic links
  • Organization Support: Use the organization plugin for multi-tenant apps
  • Rate Limiting: Add the rateLimit plugin 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