All posts
authbetter-auth

Better Auth: A Practical Guide for Full-Stack Developers

A practical guide to Better Auth — the framework-agnostic, self-hosted TypeScript auth library, and how it compares to Auth.js and Clerk.

SR

Suhail Roushan

August 6, 2026

·
4 min read
·
0 views

Better Auth showed up specifically to fix the two complaints developers had about existing TypeScript auth options: Auth.js felt limiting for anything beyond basic sessions, and Clerk/Supabase Auth meant handing user data to a third party.

Better Auth is a self-hosted, framework-agnostic TypeScript authentication library that gives you full control over your user data and database schema while still handling the hard parts — session management, OAuth flows, email verification, two-factor auth — out of the box. It runs on your own infrastructure and database, unlike hosted providers, but ships with far more built-in functionality than Auth.js's more minimal core.

Why Better Auth Matters (and When to Skip It)

Hosted auth providers (Clerk, Supabase Auth) are fast to set up but mean your user data lives in someone else's system, with pricing that scales with monthly active users. Auth.js (formerly NextAuth) is self-hosted and free but leaves a lot of functionality — organizations, two-factor auth, admin plugins — as something you build yourself. Better Auth sits in between: self-hosted like Auth.js, but with plugin-based feature coverage closer to what hosted providers offer.

Skip it if you specifically want to offload all auth infrastructure and don't mind the recurring cost and data residency tradeoff of a hosted provider — Clerk's polished pre-built UI components are hard to beat for shipping speed.

Getting Started with Better Auth

Install and configure the server instance:

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 },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
});

Mount the handler (Next.js App Router example):

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

Client-side, a typed hook gives you session state:

import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();

const { data: session } = authClient.useSession();

Core Better Auth Concepts Every Developer Should Know

The database schema is yours, generated via CLI. npx better-auth generate produces migration files for your chosen ORM (Drizzle, Prisma, Kysely) — the user, session, and account tables live in your own database, queryable with your normal tooling.

Plugins extend core functionality without bloating the base install. Two-factor auth, organizations/multi-tenancy, magic links, and passkeys are all opt-in plugins rather than baked into the core:

import { twoFactor, organization } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [twoFactor(), organization()],
});

Session management is cookie-based by default, with configurable expiry and refresh behavior — handled server-side, so you're not manually managing JWT expiry logic yourself.

Type inference flows from your config to your client, similar in spirit to tRPC — the authClient gets accurate types for session shape based on your actual betterAuth() configuration, including any custom fields you've added.

Common Better Auth Mistakes and How to Fix Them

Mistake 1: not running schema migrations after adding a plugin. Adding a plugin like twoFactor() requires new database columns/tables that the CLI generates — skipping this step causes runtime errors the first time that feature is used. Fix: re-run better-auth generate (and your ORM's migration step) after any plugin config change.

Mistake 2: exposing the server auth instance to client code. The betterAuth() instance holds secrets (OAuth client secrets, database credentials) and must stay server-only. Fix: only import auth in server-side files/route handlers; use authClient (the separate client package) in components.

Mistake 3: skipping email verification in production. It's easy to leave emailAndPassword.enabled: true without wiring up verification during development and forget before shipping. Fix: configure a real email provider and enable requireEmailVerification before launch.

When Should You Use Better Auth Instead of Clerk or Auth.js?

Use Better Auth when you want self-hosted control over user data with more built-in functionality than Auth.js's minimal core — especially for multi-tenant apps needing organizations, or apps needing 2FA/passkeys without building them from scratch. Use Clerk when shipping speed and pre-built UI matter more than data ownership. Use plain Auth.js for the simplest possible self-hosted setup with no extra plugin surface.

Better Auth in Production

Enable rate limiting on auth endpoints specifically — login and password reset endpoints are common brute-force targets, and Better Auth's built-in rate limiting config is worth turning on explicitly rather than assuming a default covers it. Also test the full OAuth callback flow in a staging environment matching your production domain, since OAuth provider redirect URI mismatches are the most common Better Auth setup failure.

If you're currently on Auth.js and hitting its limits for a feature like organizations or 2FA, Better Auth's plugin model is worth evaluating before building that feature yourself.

Related posts

Written by Suhail Roushan — Full-stack developer. More posts on AI, Next.js, and building products at suhailroushan.com/blog.

Get in touch