Auth in Next.js with Better Auth
Implementing secure, session-based authentication in Next.js App Router using the comprehensive Better Auth library.
Tags
Auth in Next.js with Better Auth
TL;DR
Better Auth provides a type-safe, database-agnostic authentication solution that integrates seamlessly with Next.js App Router, supporting OAuth, email/password, and MFA out of the box.
Prerequisites
- ›Next.js 14+ with App Router and TypeScript
- ›A PostgreSQL database (or any supported database)
- ›Google and GitHub OAuth credentials (for social login)
- ›Basic understanding of Next.js server components and route handlers
Step 1: Install and Configure Better Auth
npm install better-authCreate the Better Auth configuration file. This is the central configuration that defines your auth behavior.
// lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh session every 24 hours
},
});Set up your environment variables:
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
BETTER_AUTH_SECRET=your-random-secret-at-least-32-chars
BETTER_AUTH_URL=http://localhost:3000
Step 2: Create the API Route Handler
Better Auth handles all authentication endpoints through a single catch-all route.
// 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 all auth routes automatically:
- ›
POST /api/auth/sign-up/email-- email/password registration - ›
POST /api/auth/sign-in/email-- email/password login - ›
GET /api/auth/sign-in/social?provider=google-- OAuth redirect - ›
POST /api/auth/sign-out-- session invalidation - ›
GET /api/auth/session-- current session check
Step 3: Set Up the Client-Side Auth
Create a client instance that provides React hooks for authentication state.
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const { signIn, signUp, signOut, useSession } = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});Build the Sign-Up Form
// components/SignUpForm.tsx
"use client";
import { signUp } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function SignUpForm() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const { error } = await signUp.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
name: formData.get("name") as string,
});
if (error) {
setError(error.message || "Something went wrong");
setLoading(false);
return;
}
router.push("/dashboard");
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required minLength={8} />
</div>
{error && <p className="text-red-500">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Creating account..." : "Sign Up"}
</button>
</form>
);
}Build the Sign-In Form with OAuth
// components/SignInForm.tsx
"use client";
import { signIn } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function SignInForm() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleEmailSignIn = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const { error } = await signIn.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) {
setError(error.message || "Invalid credentials");
setLoading(false);
return;
}
router.push("/dashboard");
};
const handleOAuthSignIn = async (provider: "google" | "github") => {
await signIn.social({
provider,
callbackURL: "/dashboard",
});
};
return (
<div>
<form onSubmit={handleEmailSignIn}>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
</div>
{error && <p className="text-red-500">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
<div className="divider">or continue with</div>
<div className="oauth-buttons">
<button onClick={() => handleOAuthSignIn("google")}>
Sign in with Google
</button>
<button onClick={() => handleOAuthSignIn("github")}>
Sign in with GitHub
</button>
</div>
</div>
);
}Step 4: Session Management
Client-Side Session Access
Use the useSession hook in client components to access the current user.
// components/UserMenu.tsx
"use client";
import { useSession, signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export function UserMenu() {
const { data: session, isPending } = useSession();
const router = useRouter();
if (isPending) return <div>Loading...</div>;
if (!session) {
return <button onClick={() => router.push("/sign-in")}>Sign In</button>;
}
return (
<div className="user-menu">
<span>Welcome, {session.user.name}</span>
<button
onClick={async () => {
await signOut();
router.push("/");
}}
>
Sign Out
</button>
</div>
);
}Server-Side Session Access
Access the session in Server Components and Route Handlers using the auth instance directly.
// 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>
<h1>Dashboard</h1>
<p>Welcome back, {session.user.name}!</p>
<p>Email: {session.user.email}</p>
</div>
);
}Step 5: Middleware Protection
Protect entire route groups using Next.js middleware.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
const protectedRoutes = ["/dashboard", "/settings", "/profile"];
const authRoutes = ["/sign-in", "/sign-up"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const session = await auth.api.getSession({
headers: request.headers,
});
// Redirect unauthenticated users away from protected routes
const isProtected = protectedRoutes.some((route) => pathname.startsWith(route));
if (isProtected && !session) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
// Redirect authenticated users away from auth pages
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));
if (isAuthRoute && session) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/profile/:path*", "/sign-in", "/sign-up"],
};Step 6: Database Adapter Setup
Better Auth automatically creates the required tables when you first run the application. For production, generate and review migrations.
npx better-auth generateThis creates SQL migration files that you can review and apply to your database. The generated schema includes tables for users, sessions, accounts (for OAuth), and verification tokens.
Custom User Fields
Extend the user model with additional fields:
// lib/auth.ts
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
required: false,
},
bio: {
type: "string",
required: false,
},
},
},
// ... rest of config
});Step 7: Multi-Factor Authentication
Better Auth includes built-in TOTP-based MFA. Enable it in your configuration.
// lib/auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
// ... base config
plugins: [
twoFactor({
issuer: "MyApp", // Shows in authenticator apps
}),
],
});Update the client to include the two-factor plugin:
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";
export const {
signIn,
signUp,
signOut,
useSession,
twoFactor,
} = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
plugins: [twoFactorClient()],
});Enable MFA for a User
// components/EnableMFA.tsx
"use client";
import { twoFactor } from "@/lib/auth-client";
import { useState } from "react";
export function EnableMFA() {
const [qrCode, setQrCode] = useState<string | null>(null);
const [verificationCode, setVerificationCode] = useState("");
const handleEnable = async () => {
const { data } = await twoFactor.enable();
if (data?.totpURI) {
// Convert TOTP URI to QR code using a library like qrcode
setQrCode(data.totpURI);
}
};
const handleVerify = async () => {
const { error } = await twoFactor.verifyTotp({
code: verificationCode,
});
if (!error) {
alert("MFA enabled successfully!");
}
};
return (
<div>
{!qrCode ? (
<button onClick={handleEnable}>Enable Two-Factor Auth</button>
) : (
<div>
<p>Scan this QR code with your authenticator app:</p>
<code className="block text-sm break-all">{qrCode}</code>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter 6-digit code"
maxLength={6}
/>
<button onClick={handleVerify}>Verify</button>
</div>
)}
</div>
);
}Putting It All Together
Your authentication system now covers the full spectrum:
- ›Email/password -- registration and login with password hashing handled automatically
- ›OAuth -- Google and GitHub social login with account linking
- ›Session management -- cookie-based sessions accessible in both client and server components
- ›Route protection -- middleware guards for protected and auth-only routes
- ›MFA -- optional TOTP-based second factor for security-sensitive users
Better Auth manages the database schema, session lifecycle, and token rotation. You focus on building the UI and defining which routes need protection.
Next Steps
- ›Email verification -- enable the email verification plugin to require users to confirm their email before accessing protected features
- ›Password reset -- add the forgot password flow with time-limited reset tokens
- ›Rate limiting -- protect auth endpoints from brute force attacks using middleware-based rate limiting
- ›Audit logging -- track sign-in events, failed attempts, and MFA changes for security monitoring
- ›Role-based access control -- use the custom
rolefield to restrict pages and API routes by user role
FAQ
What is Better Auth and how is it different from NextAuth?
Better Auth is a type-safe, database-agnostic authentication library that provides a more comprehensive and developer-friendly API than NextAuth, with built-in support for session cookies, OAuth, and MFA.
How does Better Auth handle session management?
Better Auth uses standard session cookies for authentication, which are automatically managed and validated on both client and server, making it compatible with Next.js App Router's server components.
Can Better Auth handle multi-factor authentication?
Yes, Better Auth includes built-in support for multi-factor authentication (MFA), allowing you to add TOTP-based or other MFA methods to your authentication flow without additional libraries.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.