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

  1. Install (if separate package): bun add @better-auth/<plugin>.
  2. Import and add to the plugins array in lib/auth.ts. Use the dedicated import path (better-auth/plugins/two-factor, not better-auth/plugins) for tree-shaking.
  3. Add the corresponding client plugin to lib/auth-client.ts.
  4. Generate schema: npx @better-auth/cli@latest generate --output drizzle/auth-schema.ts.
  5. Reconcile with lib/schema.ts if needed, generate Drizzle migration, commit.
  6. 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 days
  • updateAge — refresh interval
  • cookieCache.maxAge / cookieCache.version — bump version to invalidate all sessions

Email flows

This repo wires everything to Resend (lib/email.ts):

FlowHook
WelcomedatabaseHooks.user.create.after
Password resetemailAndPassword.sendResetPassword
Existing user signupemailAndPassword.onExistingUserSignUp
Email verificationemailVerification.sendVerificationEmail
Org invitationorganization({ 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.ts to read auth env vars directly. The accessors handle the Vercel URL derivation; raw access breaks production.
  • Don't disable CSRF or origin checks. disableCSRFCheck and disableOriginCheck are 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_SECRET missing in env. Generate: openssl rand -base64 32.
  • "Invalid Origin" → add domain to trustedOrigins (or check getBetterAuthTrustedOrigins).
  • OAuth callback errors → verify redirect URIs in the provider's dashboard match exactly.

Where things live

FilePurpose
lib/auth.tsServer config — read this before changing anything
lib/auth-client.tsClient exports (signIn, signUp, signOut, useSession, organization, etc.)
lib/env/auth.tsTyped env accessors (getBetterAuthBaseUrl, etc.)
lib/email.tsResend wrapper (sendEmail, sendEmailAsync)
lib/email-templates.tsAll email templates
app/api/auth/[...all]/route.tsBetter Auth route handler
proxy.tsNext.js middleware — route protection
lib/schema.tsuser, session, account, verification, plus org tables

Auxiliary content