Better Auth Best Practices
Better Auth (server + client) is configured in lib/auth.ts and lib/auth-client.ts. Plugins active in this repo: nextCookies, organization, admin, sso. Always consult better-auth.com/docs for the current API.
Default stance
Read lib/auth.ts before changing anything. This repo has product-specific customizations (auto-personal-org hook, additional user fields like stripeCustomerId and walletAddress, Resend-wired email handlers) that you'll quietly break if you reach for the documented Better Auth API directly.
Env vars over config values. BETTER_AUTH_SECRET and BETTER_AUTH_URL are read by Better Auth automatically — only define secret/baseURL in config when env vars aren't set. In this repo, getBetterAuthBaseUrl() derives from Vercel deployment URLs at runtime (see lib/env/auth.ts), so the only place you set the URL manually is local dev.
Re-run the CLI after touching plugins. Plugin changes mean schema changes. npx @better-auth/cli@latest generate --output drizzle/auth-schema.ts (we use Drizzle), then run our normal migration flow (see db-health).
Use this skill when
Changing lib/auth.ts, lib/auth-client.ts, session settings, OAuth providers, email/password flow, plugin config, auth env vars, or auth-related schema.
Repo-specific patterns
// lib/auth.ts (excerpt)
export const auth = betterAuth({
baseURL: getBetterAuthBaseUrl(), // derives from Vercel URLs
trustedOrigins: getBetterAuthTrustedOrigins(),
secret: getBetterAuthSecret(),
database: drizzleAdapter(db, { provider: "pg", schema: { /* ... */ } }),
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
sendEmailAsync(user.email, resetPasswordEmail(user.name, url))
},
},
user: {
additionalFields: {
stripeCustomerId: { type: "string", required: false },
walletAddress: { type: "string", required: false },
},
},
databaseHooks: {
user: { create: { after: async (user) => {
// Auto-create personal org for every new user — see organization-best-practices
}}},
},
plugins: [nextCookies(), organization({ /* ... */ }), admin({ /* ... */ }), sso({ /* ... */ })],
})
export type Session = typeof auth.$Infer.Session
Adding a plugin
- Install (if separate package):
bun add @better-auth/<plugin>. - Import and add to the
pluginsarray inlib/auth.ts. Use the dedicated import path (better-auth/plugins/two-factor, notbetter-auth/plugins) for tree-shaking. - Add the corresponding client plugin to
lib/auth-client.ts. - Generate schema:
npx @better-auth/cli@latest generate --output drizzle/auth-schema.ts. - Reconcile with
lib/schema.tsif needed, generate Drizzle migration, commit. - Test locally, gates, then promote.
Sessions
This repo uses the Drizzle adapter (DB-backed sessions). Cookie cache is NOT enabled — sessions hit the DB. If you switch to cookie cache, custom session fields are NOT cached and will always re-fetch.
Key knobs (configure in session: { ... }):
expiresIn— default 7 daysupdateAge— refresh intervalcookieCache.maxAge/cookieCache.version— bump version to invalidate all sessions
Email flows
This repo wires everything to Resend (lib/email.ts):
| Flow | Hook |
|---|---|
| Welcome | databaseHooks.user.create.after |
| Password reset | emailAndPassword.sendResetPassword |
| Existing user signup | emailAndPassword.onExistingUserSignUp |
| Email verification | emailVerification.sendVerificationEmail |
| Org invitation | organization({ sendInvitationEmail }) |
All templates live in lib/email-templates.ts. Use sendEmailAsync (fire-and-forget) for non-blocking flows.
Hard rules
- Don't bypass
lib/env/auth.tsto read auth env vars directly. The accessors handle the Vercel URL derivation; raw access breaks production. - Don't disable CSRF or origin checks.
disableCSRFCheckanddisableOriginCheckare security holes. The repo's defaults are correct. - Don't add
"users"(plural) as the model name. The Drizzle adapter takes the model name (singular) —user,session,account— not the table name. - Don't change session expiry without testing. Existing sessions don't migrate; users get logged out.
- Don't manually delete a user via raw SQL. Use the admin plugin's API or Better Auth's user delete flow so cascade hooks fire.
Common gotchas
- Custom session fields aren't in cookie cache (if cache enabled) — always re-fetch.
- Plugin schema requires re-running CLI after add/change.
- "Secret not set" →
BETTER_AUTH_SECRETmissing in env. Generate:openssl rand -base64 32. - "Invalid Origin" → add domain to
trustedOrigins(or checkgetBetterAuthTrustedOrigins). - OAuth callback errors → verify redirect URIs in the provider's dashboard match exactly.
Where things live
| File | Purpose |
|---|---|
lib/auth.ts | Server config — read this before changing anything |
lib/auth-client.ts | Client exports (signIn, signUp, signOut, useSession, organization, etc.) |
lib/env/auth.ts | Typed env accessors (getBetterAuthBaseUrl, etc.) |
lib/email.ts | Resend wrapper (sendEmail, sendEmailAsync) |
lib/email-templates.ts | All email templates |
app/api/auth/[...all]/route.ts | Better Auth route handler |
proxy.ts | Next.js middleware — route protection |
lib/schema.ts | user, session, account, verification, plus org tables |
Auxiliary content
- references/original-guide.md — full Better Auth reference: config options, sessions, security, hooks, plugins, client APIs
- references/graph.md — handoff to
organization-best-practices,create-auth-skill,db-health - scripts/list-auth-surfaces.sh — enumerates auth-touching files (
auth.ts,auth-client.ts, env accessors, email templates, route handler, schema fields) - assets/auth-change-checklist.md — pre-merge checklist for any auth change (env vars, schema gen, gates, smoke)