Production Ready Auth with Better Auth
For a long time, we had to either pay a fortune for managed services (Clerk/Auth0) or wrestle with the absolute chaos that was maintaining your own auth sessions. But it’s 2026, and the ecosystem has finally matured.
The stack I’m opting for this one is Next.js. In this one I’m going to walk you through how I set this up in a production Next.js 15+ app. No fluff, just the code that actually works.
The Stack
I am keeping it simple here
Next.js 15+ (App Router, obviously)
Better Auth
MongoDB (Because I don’t want to write SQL, ugh)
Phase 1: The Setup
pnpm add better-auth mongodb
The Environment Variables
You know the drill. Create a .env file. Do not skip the BETTER_AUTH_SECRET.
// .ENV
MONGODB_URI=mongodb+srv:
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Phase 2: The Server-Side Config
Here is where Better Auth shines. We need to create a central Auth instance. I usually throw this in src/lib/auth.ts. The beauty here is the adapters. We don't need to manually write Mongoose schemas for users, sessions, or accounts. Better Auth handles the collections automatically.
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI!);
const db = client.db("my-production-app");
export const auth = betterAuth({
database: mongodbAdapter(db),
emailAndPassword: {
enabled: true,
},
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!,
}
}
});
Phase 3: The API Route
In older libraries, this part used to be a mess of callbacks and switch statements. Now? It’s basically two lines of code. We need a catch-all route so Better Auth can handle /api/auth/sign-in, /sign-out, etc etc
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
Phase 4: The Client
Next.js creates a strict boundary between Server and Client, we need a dedicated Auth Client to use in our React components. This gives us hooks like useSession and functions like signIn.
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL
});
export const { signIn, signUp, signOut, useSession } = authClient;
Phase 5: Building a Login Form
Let's build a proper Sign-Up page. I’m going to use the signUp function we just exported. Note how we handle the callbackURL, this controls where the user lands after they register.
"use client";
import { useState } from "react";
import { signUp } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export default function SignUp() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleRegister = async () => {
setLoading(true);
await signUp.email({
email,
password,
name,
callbackURL: "/dashboard",
}, {
onSuccess: () => {
router.push("/dashboard");
},
onError: (ctx) => {
alert(ctx.error.message);
setLoading(false);
}
});
};
return (
<div className="flex flex-col gap-4 p-10 max-w-sm mx-auto">
<h1 className="text-2xl font-bold">Join the App</h1>
{/* Social Auth is literally one function call */}
<button
onClick={() => signUp.social({ provider: "google", callbackURL: "/dashboard" })}
className="bg-white border text-black p-2 rounded flex items-center justify-center gap-2"
>
Continue with Google
</button>
<div className="text-center text-gray-500">or</div>
<input className="border p-2 rounded" placeholder="Name" onChange={(e) => setName(e.target.value)} />
<input className="border p-2 rounded" placeholder="Email" onChange={(e) => setEmail(e.target.value)} />
<input className="border p-2 rounded" type="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} />
<button
onClick={handleRegister}
disabled={loading}
className="bg-black text-white p-2 rounded hover:opacity-80 disabled:opacity-50"
>
{loading ? "Creating Account..." : "Sign Up"}
</button>
</div>
);
}
Phase 6: Protecting Routes
We need to protect our routes. In 2026, the best way to do this in the App Router isn't just client-side checking, it's Server Side Checking in a Layout.
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session) {
redirect("/sign-in");
}
return (
<div>
<header className="p-4 border-b flex justify-between items-center">
<h2 className="font-bold">Dashboard</h2>
<div className="flex gap-4 items-center">
<p>Hello, {session.user.name}</p>
{/* Note: You'll need a Client Component for the Logout button */}
</div>
</header>
<main className="p-4">
{children}
</main>
</div>
);
}
No Flash of Unauthenticated Content - Because we check in the layout (server-side), the user never sees the dashboard if they aren't allowed.
Type Safety - session.user.name is typed. I didn't have to create a custom interface, Better Auth inferred it from my schema.
Flexibility- If I want to add 2FA later? I just add
twoFactor: { enabled: true }in the config. That's it.
It’s genuinely the cleanest auth setup I’ve touched in a long time. If you want a visual walkthrough, definitely check the Coder's Gyan video linked, it was a huge help when I was figuring this out.