> your AI agent picks dependencies from memory; give it dated facts — try starlog.dev ↗ vet your agent's deps ↗ vibe-coding is fine. vibe-importing isn’t. — try starlog.dev ↗ vibe-importing isn’t fine ↗ your agent has never seen your private packages — try starlog.dev ↗ facts for private packages ↗ a linter for the dependencies your AI agent picks — try starlog.dev ↗ a linter for agent deps ↗

Back to Articles

Better Auth: Type-Safe Authentication That Doesn't Hit Your Database on Every Request (Eventually)

[ View on GitHub ]

Better Auth: Type-Safe Authentication That Doesn't Hit Your Database on Every Request (Eventually)

Hook

Most authentication frameworks force you to choose between type safety and flexibility. Better Auth lets you have both—by making the TypeScript compiler do the heavy lifting at build time, not runtime.

Context

The TypeScript ecosystem has a dirty secret: authentication libraries haven't kept pace with the rest of the stack. While tRPC and Zod brought end-to-end type safety to API development, auth remained stuck in the stringly-typed world of environment variables and runtime configuration. NextAuth popularized the adapter pattern but couldn't deliver true type inference across client and server boundaries. Clerk solved the DX problem by becoming a hosted service—and introduced vendor lock-in plus per-user pricing that scales brutally.

Better Auth emerged to fill this gap: a self-hosted authentication framework that treats TypeScript as a first-class concern, not an afterthought. It's built for teams running modern TypeScript monorepos who want Auth0-level features (social auth, 2FA, organizations, SSO) without the hosted service costs or the type-safety compromises of older libraries. The core insight is that if your configuration is TypeScript, the framework can generate both server handlers and client SDKs from the same source of truth—eliminating the client/server type drift that plagues traditional auth setups.

Technical Insight

Request Pipeline

Storage

defines schema

plugin composition

manages

Drizzle/Prisma/Kysely

encrypted cookies/tokens

typed requests

OAuth flows

email/password

injects endpoints

type extraction

session data

Configuration Object

Core Engine

Plugin Registry

Database Adapter

Database

Session Store

Client SDK

Middleware Hooks

OAuth Provider Normalizer

Auth Handlers

System architecture — auto-generated

Better Auth's architecture centers on compile-time composition rather than runtime discovery. When you configure the framework, you're building a type-level schema that gets transformed into both server endpoints and a client proxy object. Here's what a basic setup looks like:

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,
    requireEmailVerification: 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!
    }
  },
  plugins: [
    twoFactor(),
    organization()
  ]
});

export type Auth = typeof auth;

The magic happens when you generate the client. Import the Auth type on your frontend, and the client SDK knows exactly what endpoints exist, what parameters they accept, and what they return:

import { createAuthClient } from "better-auth/client";
import type { Auth } from "./auth.server";

const authClient = createAuthClient<Auth>({
  baseURL: "http://localhost:3000"
});

// Full autocomplete and type checking
await authClient.signIn.email({
  email: "user@example.com",
  password: "secure-password"
});

// TypeScript knows this endpoint exists because
// you included the twoFactor() plugin
await authClient.twoFactor.verify({
  code: "123456"
});

This works through TypeScript's template literal types and conditional type inference. The client is actually a Proxy object that intercepts method calls and transforms them into HTTP requests to the corresponding server endpoint. Because the server exports its type signature, the client can mirror it exactly—no code generation step, no build scripts, just type-level programming.

The plugin system extends this pattern. Plugins declare schema additions using a composition API that merges into the core types. The organization() plugin, for example, adds new database tables, new endpoints (/organization/create, /organization/invite), and new client methods—all with full type safety:

import { createPlugin } from "better-auth/plugins";

const customPlugin = createPlugin({
  id: "custom-auth-flow",
  schema: {
    // Extend the user table
    user: {
      fields: {
        customField: {
          type: "string",
          required: false
        }
      }
    }
  },
  endpoints: {
    customEndpoint: {
      method: "POST",
      handler: async ({ body, context }) => {
        // Full access to database, session, etc.
        const user = await context.getUser();
        return { success: true };
      }
    }
  }
});

Database adapters abstract over query builders rather than SQL dialects. Instead of writing lowest-common-denominator queries that work across Postgres, MySQL, and SQLite, Better Auth translates framework operations into builder-specific queries. The Drizzle adapter uses Drizzle's query builder, the Prisma adapter uses Prisma Client, and so on. This means you get the full power of your chosen ORM—including type-safe relations, transactions, and advanced filtering—without the framework imposing artificial limitations.

Session management deserves special attention because Better Auth handles a use case most frameworks ignore: migrating from stateless to stateful sessions without downtime. You can configure both JWT-style tokens and database-backed sessions simultaneously, with automatic fallback. The framework checks for a database session first, falls back to validating the JWT if none exists, and can progressively upgrade users to database sessions over time. This is critical for teams scaling beyond the point where JWTs make sense (typically when you need instant logout, permission changes that propagate immediately, or audit logging of active sessions).

Gotcha

The database-per-request problem is real and poorly documented. By default, Better Auth validates sessions by querying your database on every authenticated request. There's no built-in caching layer for user lookups or session validation, which means you're hitting your database far more than you probably expect. At 100 requests per second, that's 100 database queries—just for auth. The framework documentation barely mentions this, and the recommended solution is "use Redis for sessions," which requires configuring a separate session store and complicates deployment.

The framework-agnostic claims need asterisks. While Better Auth technically works with SvelteKit, Remix, SolidStart, and others, the documentation is overwhelmingly Next.js-centric. You'll find dozens of Next.js App Router examples and maybe two paragraphs on SvelteKit integration. The adapter pattern for different frameworks exists, but you're largely on your own figuring out middleware setup, cookie handling, and SSR integration. If you're not on Next.js, budget extra time for trial and error—the community hasn't built up the critical mass of examples and troubleshooting guides you'd find with NextAuth.

Database migrations are a manual nightmare. Better Auth generates TypeScript schema definitions for your tables, but it doesn't provide migration tooling. When you add a plugin or upgrade to a new version that adds schema fields, you're responsible for writing and running migrations yourself. There's no better-auth migrate command. For teams using Drizzle or Prisma, you can generate migrations from the schema, but the framework doesn't automate this or even clearly document the migration path between versions. Expect schema drift issues if you're not meticulous about version control.

Verdict

Use if: You're building a TypeScript monorepo with Next.js or a modern React framework, already using Drizzle or Prisma, and need complex authentication patterns (social + email + 2FA + organizations) with full type safety across your stack. Better Auth excels when you want self-hosted control without sacrificing developer experience, and when you're willing to handle deployment complexity in exchange for avoiding per-user pricing. It's ideal for startups and mid-size SaaS products where auth requirements will grow over time and the plugin architecture prevents dead code bloat.

Skip if: You're not on Next.js (the DX penalty is real), need production-ready WebAuthn or passkeys (still experimental), require database-per-tenant isolation for compliance, or operate at scale where session validation latency matters. Also skip if you want migration tooling that just works—you'll spend more time managing schema changes than you expect. For non-TypeScript projects, simple auth needs, or teams that can't dedicate time to infrastructure, NextAuth v5 or a hosted service like Clerk will save you weeks of pain.