Better Auth
What if your auth library lived in your database, not someone else's cloud?
Better Auth is a TypeScript-first, self-hosted authentication library. Open source, no SaaS dependency, full control over data and testing. As of 2026, the Better Auth team also maintains Auth.js (formerly NextAuth), signalling ecosystem consolidation.
Why Consider It
| Pain with Clerk | Better Auth Answer |
|---|---|
| Bot detection breaks E2E tests | Your database, your rules -- no bot gates |
| No staging instance, manual config sync | Same codebase, different DB connection |
| 100 user cap in dev | Unlimited -- it's your database |
| Unit tests need custom mock layer | Test against real auth with test DB |
| External dependency for every CI run | Runs locally, no network calls |
| Per-seat pricing at scale | Free forever (open source) |
Architecture
Client (Next.js)
|
v
Better Auth SDK (server-side)
|
v
Your Database (Drizzle / Prisma)
|
v
Sessions, Users, Accounts, Orgs -- all in your schema
Self-hosted. Database-backed sessions (default 7-day expiry, 1-day auto-renewal). Optional JWT mode for horizontal scaling. No external auth service to configure, sync, or pay for.
Key Features
| Feature | Status |
|---|---|
| Email/password | Built-in with scrypt hashing |
| OAuth providers | 50+ built-in (Google, GitHub, Apple, etc.) |
| MFA | TOTP and SMS via plugin |
| Organizations | Multi-tenant with membership, roles via plugin |
| RBAC | Fine-grained permissions per org |
| Rate limiting | Built-in on all routes, stricter on auth endpoints |
| CSRF protection | Origin header validation, configurable trusted origins |
| WebAuthn | Passkey support via plugin |
| Session management | Revoke per-device, admin force-logout |
Next.js Integration
Setup with Drizzle
// lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
// lib/auth.ts
import { betterAuth } from "@better-auth/next";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import * as authSchema from "@/db/auth-schema"; // CLI-generated
export const auth = betterAuth({
adapter: drizzleAdapter(db, { schema: authSchema }),
// plugins: [organizationsPlugin(), mfaPlugin()]
});
Generate schema and migrations via CLI:
npx @better-auth/cli@latest generate # generates Drizzle schema + relations
npx drizzle-kit generate # generate SQL migrations
npx drizzle-kit push # apply to database
Middleware
// middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function middleware(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session && req.nextUrl.pathname.startsWith("/app")) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/app/:path*"] };
Server Components
// lib/auth-helpers.ts
import { auth } from "@/lib/auth";
export async function requireSession() {
const session = await auth.api.getSession();
if (!session) throw new Error("UNAUTHENTICATED");
return session;
}
export async function requireOrgRole(orgSlug: string, role: string) {
const session = await requireSession();
const membership = session.user.orgs?.find((m) => m.slug === orgSlug);
if (!membership || !membership.roles.includes(role)) {
throw new Error("FORBIDDEN");
}
return { session, membership };
}
// app/(app)/[org]/page.tsx
import { requireOrgRole } from '@/lib/auth-helpers';
export default async function OrgPage({ params }: { params: { org: string } }) {
const { session } = await requireOrgRole(params.org, 'admin');
return <div>Welcome {session.user.name}</div>;
}
Testing
The key advantage: no external service dependency. Test against a real auth instance backed by a test database.
Integration Tests
// test/auth-setup.ts
import { betterAuth } from "@better-auth/next";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const testClient = postgres(process.env.TEST_DATABASE_URL!);
const testDb = drizzle(testClient);
export const testAuth = betterAuth({
adapter: drizzleAdapter(testDb, { schema: authSchema }),
});
beforeAll(async () => {
await migrate(testDb, { migrationsFolder: "./drizzle" });
});
it("creates user and authenticates", async () => {
await testAuth.api.signUp({
email: "test@example.com",
password: "secure-password",
});
const session = await testAuth.api.signIn({
email: "test@example.com",
password: "secure-password",
});
expect(session.user.email).toBe("test@example.com");
});
Unit Tests
For components that consume auth, mock the auth helpers (not the library):
vi.mock("@/lib/auth-helpers", () => ({
requireSession: async () => ({
user: { id: "user_1", email: "test@example.com", orgs: [] },
}),
}));
E2E Tests (Playwright)
No testing tokens, no bot detection. Standard Playwright form interaction:
test("sign in flow", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "test@example.com");
await page.fill('[name="password"]', "secure-password");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
});
Migration from Clerk
Data Mapping
| Clerk Entity | Better Auth Equivalent |
|---|---|
| Users | user table |
| External Accounts | account table (OAuth links) |
| Organizations | organization table (plugin) |
| Org Memberships | member table with roles |
| Sessions | session table (DB-backed) |
Migration Steps
- Export users from Clerk (Admin API or Dashboard)
- Write migration script inserting into Better Auth's Drizzle tables
- Run against staging DB first, dry-run against production exports
- Cut-over: freeze Clerk writes, run migration, switch frontend
- Update downstream services that consumed Clerk JWTs
Dual-System Bridge
If you need gradual migration: accept Clerk JWTs temporarily. On first Better Auth login, link or create the user record and migrate incrementally.
Known Limitations
| Limitation | Mitigation |
|---|---|
| Smaller ecosystem than NextAuth | Growing fast (14K+ GitHub stars), Auth.js team now maintains |
| No WAF/global throttling | Add rate limiting at reverse proxy layer |
| Schema evolves with upgrades | Lock versions, regenerate schema in staging first |
| Large org counts bloat session | Store only active org in session, fetch memberships on demand |
| Newer than Clerk/Auth0 | Core features solid, increasing production adoption |
| No managed dashboard | Build your own admin UI or use DB tools |
Comparison
| Dimension | Clerk | Better Auth | Auth.js/NextAuth |
|---|---|---|---|
| Hosting | Managed SaaS | Self-hosted | Self-hosted |
| Database | Clerk-managed | Your DB (Drizzle/Prisma) | Your DB (adapters) |
| TypeScript | SDK with types | Native, full inference | Declaration-based |
| Multi-tenant | Built-in | Plugin | Custom implementation |
| MFA | Built-in | Plugin | Custom implementation |
| Testing | Requires mocks + testing tokens | Test DB, no external deps | Test DB, no external deps |
| Pricing | Free tier, then per-seat | Free (open source) | Free (open source) |
| Setup time | Minutes | Hours | Hours |
| Ecosystem | Largest | Growing | Largest open-source |
| Maintenance | Vendor manages | You manage | You manage |
Context
- Identity and Security -- The evaluation checklist
- Clerk Auth -- Managed alternative, testing deep dive
- Trust Architecture -- Trust infrastructure patterns
- Product Engineering -- Engineering standards
Links
- Better Auth Documentation -- Official docs
- Better Auth Drizzle Adapter -- Drizzle setup guide
- Better Auth vs NextAuth -- Feature comparison
- Auth.js maintained by Better Auth team -- Ecosystem consolidation
Questions
When does the setup cost of self-hosting pay back in testing speed and operational control?
- What's the break-even point where Clerk's per-seat pricing exceeds self-hosted infrastructure costs?
- How much CI time do you lose to Clerk's bot detection vs how much setup time does Better Auth require?
- If Better Auth now maintains Auth.js, does that make it the default choice for new Next.js projects?
- What org/role complexity level makes the plugin model insufficient?