Better Auth Integration Guide

Technology: better-auth · Category: auth · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/better-auth

Insight:

Better Auth is the self-hosted counterweight to Clerk — the user, session, account and verification tables live in your Postgres, so auth data joins directly against app data and there is no per-MAU bill. You trade a managed dashboard for full ownership: you run the migrations, you secure the tables, you send the emails and the SMS.

██████╗ ███████╗████████╗████████╗███████╗██████╗      █████╗ ██╗   ██╗████████╗██╗  ██╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗    ██╔══██╗██║   ██║╚══██╔══╝██║  ██║
██████╔╝█████╗     ██║      ██║   █████╗  ██████╔╝    ███████║██║   ██║   ██║   ███████║
██╔══██╗██╔══╝     ██║      ██║   ██╔══╝  ██╔══██╗    ██╔══██║██║   ██║   ██║   ██╔══██║
██████╔╝███████╗   ██║      ██║   ███████╗██║  ██║    ██║  ██║╚██████╔╝   ██║   ██║  ██║
╚═════╝ ╚══════╝   ╚═╝      ╚═╝   ╚══════╝╚═╝  ╚═╝    ╚═╝  ╚═╝ ╚═════╝    ╚═╝   ╚═╝  ╚═╝

Better Auth Integration Guide

Focus: framework-agnostic, self-hosted TypeScript auth — email/password, social OAuth, sessions, and a first-party plugin ecosystem (organizations, 2FA, passkeys, phone OTP, Stripe billing) running entirely inside your own Next.js app and your own Postgres.

Overview

Better Auth is a library, not a service. You call betterAuth({...}) in your own code, point it at your own database, and mount a single catch-all route. No external identity provider sits in the request path — signing in writes a row to your session table and sets an HttpOnly cookie signed with your BETTER_AUTH_SECRET.

That inversion is the whole trade-off against Clerk and Auth0:

Clerk / Auth0 Better Auth
User data lives in the vendor's database your Postgres (user, session, account, verification)
Joining users to app data webhook-synced mirror table a plain SQL JOIN
Pricing per monthly-active-user your database bill
Pre-built UI hosted components you build the forms
Email / SMS delivery included you wire it (Resend, Africa's Talking)
Ops burden none migrations, secret rotation, table security
flowchart LR
  A["Browser<br/>authClient"] -->|"fetch /api/auth/*"| B["Catch-all route<br/>toNextJsHandler(auth)"]
  B --> C["betterAuth() instance<br/>lib/auth.ts"]
  C --> D["Adapter<br/>drizzle · prisma · pg"]
  D --> E[("Your Postgres<br/>user · session<br/>account · verification")]
  C --> F["Plugins<br/>organization · 2FA<br/>phoneNumber · stripe"]
  B -.->|"Set-Cookie<br/>HttpOnly · Secure · SameSite=Lax"| A

Official Documentation

Resource URL
Docs home https://www.better-auth.com/docs
Installation https://www.better-auth.com/docs/installation
Next.js integration https://www.better-auth.com/docs/integrations/next
Database & adapters https://www.better-auth.com/docs/concepts/database
Session management https://www.better-auth.com/docs/concepts/session-management
CLI https://www.better-auth.com/docs/concepts/cli
Source https://github.com/better-auth/better-auth

Setup

1. Install

npm install better-auth

2. Generate a secret

BETTER_AUTH_SECRET signs session cookies and encrypts stored OAuth tokens. It must be high-entropy and at least 32 characters. The CLI generates one:

npx auth@latest secret

CLI package name: the current CLI ships as the bare npm package auth — so it is npx auth .... The older @better-auth/cli name is frozen at 1.4.x; do not use it against a current install.

3. Environment variables

# .env.local — server-side only, never NEXT_PUBLIC_*
BETTER_AUTH_SECRET=<32+ char secret from the CLI>
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=<postgres connection string>

# Only for the social providers you actually enable
GOOGLE_CLIENT_ID=<from Google Cloud console>
GOOGLE_CLIENT_SECRET=<from Google Cloud console>

Better Auth resolves the secret as options.secret → BETTER_AUTH_SECRET → AUTH_SECRET, and throws in production when none is set.

4. Create the auth instance

lib/auth.ts — this module imports your database driver and reads the secret, so it is server-only. Never import it from a client component.

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "@/db";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  emailAndPassword: { enabled: true },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    },
  },
  plugins: [nextCookies()], // MUST be the last entry in this array
});

Other adapters take the same shape:

import { prismaAdapter } from "better-auth/adapters/prisma";
// database: prismaAdapter(prisma, { provider: "postgresql" })

import { Pool } from "pg";
// database: new Pool({ connectionString: process.env.DATABASE_URL })

nextCookies() works by post-processing the response to write cookies set during server actions. Any plugin listed after it never gets its cookies written — this is the single most common misconfiguration.

5. Create the database tables

With a direct driver (pg, better-sqlite3, mysql2) Better Auth applies migrations itself:

npx auth migrate

With an ORM adapter (Drizzle, Prisma) it emits schema instead, and you run your ORM's own migration tool afterwards:

npx auth generate --adapter drizzle

That produces the four core tables — user, session, account, verification — plus one table per schema-carrying plugin. Field facts worth knowing:

6. Mount the route handler

app/api/auth/[...all]/route.ts:

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

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

7. Create the client

lib/auth-client.ts:

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
});

export const { signIn, signUp, signOut, useSession } = authClient;

The import path is per framework — better-auth/react, /vue, /svelte, /solid, or the vanilla better-auth/client.


Key patterns

Sign in and sign up

Every client method returns { data, error } — it does not throw.

"use client";
import { authClient } from "@/lib/auth-client";

async function onSubmit(email: string, password: string) {
  const { data, error } = await authClient.signIn.email({ email, password });
  if (error) return showError(error.message);
  router.push("/dashboard");
}

Social sign-in redirects the browser:

await authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" });

Reading the session

Server (server component, route handler, server action) — authoritative, hits the database:

import { headers } from "next/headers";
import { auth } from "@/lib/auth";

const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");

Client — reactive hook:

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

In Next.js middleware, check only that the session cookie exists. Do not call the database or the API there; middleware runs on every matched request and will block them.

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  const { pathname } = request.nextUrl;

  if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
  if (!sessionCookie && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*", "/login", "/signup"] };

This is a redirect optimisation, not an authorization boundary. A cookie can be present and invalid. Re-check with auth.api.getSession() inside every protected route, action, and handler.

session: {
  expiresIn: 60 * 60 * 24 * 7, // 7 days total lifetime
  updateAge: 60 * 60 * 24,     // slide the expiry at most once a day
  freshAge: 60 * 5,            // "recently authenticated" window for sensitive ops
  cookieCache: { enabled: true, maxAge: 5 * 60 },
}

cookieCache trades correctness for latency: session reads come from a short-lived signed cookie instead of the database. A revoked session stays live on other devices until maxAge expires. Keep it short, and leave it off wherever immediate revocation is a requirement.

Cookie defaults are already production-shaped: HttpOnly, SameSite=Lax, and Secure auto-enabled when the resolved base URL is HTTPS or NODE_ENV is production.

Plugins

Plugins come in matched server + client pairs, and any that carry schema require a re-run of npx auth generate.

// lib/auth.ts (server)
import { organization, twoFactor, magicLink, admin } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";

plugins: [
  organization(),
  twoFactor(),
  magicLink({ sendMagicLink: async ({ email, url }) => sendEmail(email, url) }),
  admin(),
  passkey(),
  nextCookies(), // always last
]
// lib/auth-client.ts
import {
  organizationClient, twoFactorClient, magicLinkClient, adminClient,
} from "better-auth/client/plugins";
import { passkeyClient } from "@better-auth/passkey/client";

plugins: [organizationClient(), twoFactorClient(), magicLinkClient(), adminClient(), passkeyClient()]

Import-path trap: passkey lives in its own package (@better-auth/passkey and @better-auth/passkey/client). The others are in core (better-auth/plugins and better-auth/client/plugins). Mixing these up is the most common copy-paste failure.

twoFactorClient() takes an onTwoFactorRedirect callback that fires when a sign-in needs a second factor:

twoFactorClient({
  onTwoFactorRedirect({ twoFactorMethods }) {
    window.location.href = "/2fa";
  },
})

Phone-number OTP

Phone auth is a first-class plugin — you supply the delivery function:

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

phoneNumber({
  otpLength: 6,
  expiresIn: 300,
  requireVerification: true,
  phoneNumberValidator: (n) => /^254\d{9}$/.test(n),
  sendOTP: async ({ phoneNumber, code }) => {
    await sendSms(phoneNumber, `Your code is ${code}`);
  },
})

Client side: authClient.phoneNumber.sendOtp({ phoneNumber }), then authClient.phoneNumber.verify({ phoneNumber, code }).

Rate limiting

Rate limiting runs before any plugin hook or route handler and short-circuits with a 429.

rateLimit: {
  enabled: true,
  window: 10,
  max: 100,
  customRules: { "/sign-in/email": { window: 60, max: 5 } },
  storage: "database",
}

On Vercel, "memory" gives effectively no protection — each function instance keeps its own counter. Use "database", or a secondaryStorage backed by Redis/Upstash.

Database hooks

Lifecycle side-effects, with the ability to abort:

databaseHooks: {
  user: {
    create: {
      after: async (user) => { await provisionWorkspace(user.id); },
    },
    delete: {
      before: async (user) => !user.email.endsWith("@codeamani.com"),
    },
  },
}

Returning false from a before hook aborts the operation.

Stripe billing

@better-auth/stripe binds Stripe customers to Better Auth users, and optionally to organizations:

npm install @better-auth/stripe
import { stripe } from "@better-auth/stripe";

plugins: [
  organization(),
  stripe({
    createCustomerOnSignUp: true,
    subscription: {
      enabled: true,
      plans: [{ name: "pro", priceId: process.env.STRIPE_PRO_PRICE_ID as string }],
    },
    organization: { enabled: true }, // bill the org, not the individual
    onEvent: async (event) => {
      switch (event.type) {
        case "invoice.paid":
          break;
      }
    },
  }),
]

codeAmani notes

When to reach for Better Auth over Clerk

Clerk stays the default for codeAmani projects — hosted UI and zero ops win for most US-first SaaS. Reach for Better Auth when one of these holds:

  1. Auth data must join app data. Multi-tenant reporting, per-user analytics, and admin tooling get dramatically simpler when user is a real table sitting next to your domain tables instead of a webhook-synced mirror.
  2. Per-MAU pricing breaks the model. The Kenya-targeted builds (duka-order-bot, boda-dispatch, sacco-chama-assistant) expect large low-ARPU user counts. A per-MAU bill in USD against KES revenue does not survive contact with the spreadsheet; a Neon or Supabase row does.
  3. Phone-first identity. Many East African users have a reliable phone number and no reliable email. The phoneNumber plugin makes SMS OTP a primary credential rather than a bolt-on — pair sendOTP with Africa's Talking and normalise to 254XXXXXXXXX inside phoneNumberValidator, matching the M-Pesa phone-format rule in MPESA_PATTERNS.md.

Security

Ops

Official docs: