Better-Auth Setup Guide for React: Self-Hosted Auth Without the Monthly Bill
Every SaaS project starts the same way: sign up, log in, protect a route. I've written this auth plumbing at least a dozen times. Clerk was magic until the invoice hit $25/month at 1,000 users. NextAuth felt "free" until I needed social login, sessions, and email verification in the same week and hit a wall of underdocumented edge cases.
Better-Auth is the library I wish I'd found sooner. It's self-hosted, open-source, TypeScript-first, and ships with sessions, OAuth, email/password, and 2FA out of the box. Here's how to get it running in a React app in under 30 minutes.
What Is Better-Auth (And Why Should You Care)?
Better-Auth is an open-source TypeScript authentication library. Unlike Clerk, your user data lives in your own database — not on a third-party server. Unlike Auth.js (NextAuth), it's not tightly coupled to Next.js and works with any backend: Express, Hono, Bun, TanStack Start, or a plain HTTP server.
Here's what you get out of the box:
- Email/password authentication with verification flows
- Social OAuth (GitHub, Google, Discord, and more)
- Session management (database-backed, not just JWTs)
- Password reset and email verification built-in
- 2FA, passkeys, magic links, and impersonation as opt-in plugins
- Full TypeScript types — no
anyhacks
I wrote a full comparison in Better-Auth vs Clerk: Why I Stopped Paying for Clerk if you want the deeper breakdown. But if you've already decided and just want the setup — read on.
Installing Better-Auth
bun add better-auth
# or: npm install better-auth / pnpm add better-auth
Better-Auth needs a database to persist users and sessions. It supports Drizzle ORM and Prisma adapters, plus raw database drivers. For this guide I'm using Drizzle + PostgreSQL (the production-grade choice for SaaS).
Generate the Schema
Run the CLI to generate your database tables:
bunx better-auth generate
This outputs the SQL migrations for user, session, account, and verification tables. Apply them with your migration tool of choice.
Server Config
Create src/lib/auth.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db"; // your Drizzle db instance
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg", // or "sqlite" for local dev
}),
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!,
},
},
});
Then expose it as an API endpoint. With Hono on Bun:
import { Hono } from "hono";
import { auth } from "./lib/auth";
const app = new Hono();
// Better-Auth handles all /api/auth/* routes automatically
app.on(["POST", "GET"], "/api/auth/**", (c) => {
return auth.handler(c.req.raw);
});
Better-Auth auto-generates handlers for /api/auth/sign-in, /api/auth/sign-up, /api/auth/session, /api/auth/sign-out, and all OAuth callback URLs. You don't wire these manually.
Setting Up the React Client
Install the React client:
bun add @better-auth/react
Create src/lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_APP_URL, // e.g. http://localhost:3000
});
export const { signIn, signUp, signOut, useSession } = authClient;
Now you have fully-typed auth methods and hooks available everywhere in your app.
Sign In Form
import { signIn } from "@/lib/auth-client";
async function handleSignIn(email: string, password: string) {
const { data, error } = await signIn.email({
email,
password,
callbackURL: "/dashboard",
});
if (error) {
toast.error(error.message);
}
}
// OAuth sign-in is one line:
await signIn.social({ provider: "github", callbackURL: "/dashboard" });
Session Hook
import { useSession } from "@/lib/auth-client";
function Dashboard() {
const { data: session, isPending } = useSession();
if (isPending) return <LoadingSpinner />;
if (!session) return <Navigate to="/login" />;
return <div>Welcome back, {session.user.name} 👋</div>;
}
useSession() handles loading states, automatic re-fetching after sign-in, and null safety — no boilerplate required.
Protecting Routes
Here's a clean protected route wrapper that works with TanStack Router or React Router:
import { useSession } from "@/lib/auth-client";
import { Navigate, Outlet } from "@tanstack/react-router";
export function ProtectedRoute() {
const { data: session, isPending } = useSession();
if (isPending) return <FullPageLoader />;
if (!session?.user) return <Navigate to="/login" />;
return <Outlet />;
}
Wrap any route that requires authentication:
const router = createRouter({
routeTree: rootRoute.addChildren([
publicRoute,
ProtectedRoute.addChildren([dashboardRoute, settingsRoute]),
]),
});
The Complete Auth Flow
flowchart LR
A[User visits /dashboard] --> B{useSession: valid?}
B -->|No| C[Redirect to /login]
B -->|Yes| D[Render dashboard]
C --> E[Email/Password OR OAuth]
E --> F[POST /api/auth/sign-in]
F --> G[Better-Auth validates credentials]
G --> H[Session created in DB]
H --> D
No token juggling, no manual cookie parsing. Better-Auth handles it end to end.
Why Not Clerk?
I've used Clerk on two projects. The DX is genuinely excellent. But the math doesn't work for indie hackers:
| Better-Auth | Clerk | |
|---|---|---|
| Cost | $0/month (self-hosted) | $0.02/MAU after free tier |
| At 10k users | ~$0 | ~$200/month |
| Data ownership | Your database | Clerk's servers |
| Framework | Any TypeScript | Optimized for Next.js |
| Vendor lock-in | None | High |
| Setup time | ~30 min | ~10 min |
Clerk's extra 20 minutes of setup time saved isn't worth $200/month when you scale. And you're betting your user data on a third party — if Clerk has an outage, your users can't log in.
For TanStack Start specifically, check out TanStack Start Auth Setup: The Right Way for the framework-specific wiring details and how sessions integrate with TanStack's loader pattern.
Frequently Asked Questions
Does Better-Auth work outside Next.js? Absolutely. Better-Auth is framework-agnostic by design. It works with any TypeScript backend — Hono, Express, Fastify, Bun HTTP, or TanStack Start. The React client works in any React app regardless of meta-framework.
Does Better-Auth support social login (Google, GitHub)?
Yes. OAuth providers are first-class. You supply a clientId and clientSecret, and Better-Auth handles the redirect, callback, token exchange, and user creation automatically.
Is Better-Auth production-ready in 2026? Yes. It's actively maintained, widely used in production SaaS apps, and has a stable API. The plugin ecosystem (2FA, passkeys, magic links, multi-tenancy) keeps growing. Check the docs for the latest.
What database does Better-Auth require? Better-Auth supports PostgreSQL, MySQL, SQLite, and any database that Drizzle or Prisma can connect to. For production SaaS, Postgres is the standard choice.
Can I migrate from Clerk to Better-Auth? Yes, with some manual work. Export your users from Clerk, import them into your database with hashed passwords reset via forced password-reset emails. There's no automated tool yet, but it's a one-time migration worth doing.
Auth is the most repeated boilerplate in SaaS development — and the last thing you should be debugging when you have a product to ship. BetterStarter ships with Better-Auth, Drizzle ORM, Stripe, and Plunk email pre-wired and tested, so you open the repo and start building features on day one. One-time $99, yours forever.