Better Auth Integration Guide
What is Better Auth?
Self-hosted TypeScript auth where the trade-off is ownership for ops, and every default is a knob.
lib/auth.ts is server-only — it imports the DB driver and reads the secret, so importing it from a client component drags both into the bundle. nextCookies() must be the LAST plugin in the array or later plugins never get cookies written. Middleware's getSessionCookie() checks presence, not validity, so it is a redirect optimisation and never an authorization boundary — re-check with auth.api.getSession({ headers: await headers() }) in every protected route. session.cookieCache trades correctness for latency: a revoked session stays live on other devices until maxAge expires. For codeAmani, the case is sharpest on the Kenya-targeted builds — a per-MAU bill in USD against KES revenue does not survive the spreadsheet, and the phoneNumber plugin makes SMS OTP a primary credential (validate to 254XXXXXXXXX, wire sendOTP to Africa's Talking) for users who have a reliable phone and no reliable email.
Six Better Auth primitives
One server instance, one mounted route, one typed client — plugins extend all three at once.
██████╗ ███████╗████████╗████████╗███████╗██████╗ █████╗ ██╗ ██╗████████╗██╗ ██╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ ██╔══██╗██║ ██║╚══██╔══╝██║ ██║
██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ ███████║██║ ██║ ██║ ███████║
██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ ██╔══██║██║ ██║ ██║ ██╔══██║
██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ ██║ ██║╚██████╔╝ ██║ ██║ ██║
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝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 |
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-auth2. 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 secretCLI package name: the current CLI ships as the bare npm package
auth— so it isnpx auth .... The older@better-auth/cliname is frozen at1.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 migrateWith an ORM adapter (Drizzle, Prisma) it emits schema instead, and you run your ORM's own migration tool afterwards:
npx auth generate --adapter drizzleThat produces the four core tables — user, session, account, verification — plus one table per schema-carrying plugin. Field facts worth knowing:
account.passwordholds the password hash. It exists even with email/password disabled, and is marked non-returned so it never leaves the API.account.accessToken/refreshToken/idTokenare likewise non-returned.session.tokenis unique;session.userIdis a cascading FK with an index.- A
rateLimittable is created only whenrateLimit.storage === "database". The default"memory"adds no table.
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();Middleware — cookie presence only
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 lifetime and the cookie cache
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:
passkeylives in its own package (@better-auth/passkeyand@better-auth/passkey/client). The others are in core (better-auth/pluginsandbetter-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/stripeimport { 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:
- Auth data must join app data. Multi-tenant reporting, per-user analytics, and admin tooling get dramatically simpler when
useris a real table sitting next to your domain tables instead of a webhook-synced mirror. - 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. - Phone-first identity. Many East African users have a reliable phone number and no reliable email. The
phoneNumberplugin makes SMS OTP a primary credential rather than a bolt-on — pairsendOTPwith Africa's Talking and normalise to254XXXXXXXXXinsidephoneNumberValidator, matching the M-Pesa phone-format rule inMPESA_PATTERNS.md.
Security
BETTER_AUTH_SECRETis server-side only. It signs cookies and encrypts stored OAuth tokens, so a leak is a full session-forgery primitive. NeverNEXT_PUBLIC_, never in a client component. Store it in Hazina; note that rotating it invalidates every live session.- Never import
lib/auth.tsfrom client code. It pulls the database driver and the secret into the module graph. Client code importslib/auth-client.tsonly. - Middleware is not authorization.
getSessionCookie()checks presence, not validity. Every protected server route and action re-checks withauth.api.getSession(). - Secure the auth tables yourself. Better Auth connects over a direct Postgres connection and does not go through PostgREST. On Supabase that means its tables are not covered by anything you configured for the API — either keep them out of the exposed schema, or write RLS policies for them.
accountholds password hashes and OAuth refresh tokens; treat it like a secrets table. - Rate-limit the credential routes. Set
storage: "database"plus a tightcustomRulesentry on/sign-in/email. The default in-memory limiter is per-instance and does nothing on serverless. - Webhook signature verification still applies, but to plugins. Better Auth is self-hosted and has no inbound provider webhook of its own to verify (unlike Clerk's Svix events). The webhook surface arrives with
@better-auth/stripe, which receives real Stripe events — verify the Stripe signing secret against the raw request body perSECURITY.md. - Email and SMS delivery are yours. Verification and reset links only exist if you send them. Wire
sendVerificationEmailandsendResetPasswordto Resend before enablingrequireEmailVerification, or users get locked out silently.
Ops
- Pin the version. Better Auth moves fast, and the plugin packages track the same release train as core —
better-auth,@better-auth/stripe, and@better-auth/passkeyshould be upgraded together. - Re-run
npx auth generateafter every plugin addition, then commit the generated migration. A missing plugin table fails at runtime, not at build. - Use a pooled connection string on Vercel; the adapter opens connections per invocation.