Organization Best Practices
Multi-tenant via Better Auth's organization plugin. Every user gets a personal org on signup. Every product table that holds tenant data has organizationId. The active org is stored in the session and scopes most API calls.
Default stance
Org-first, not user-first. New product tables should have organizationId, not userId (or both, but organizationId is the boundary that matters). API keys, webhooks, tasks, billing — all scoped to org. User-scoped legacy code exists for backward compatibility with pre-org keys, but new code should use "org" everywhere it asks for a tenant type.
Use the lifecycle helpers, not raw deletes. When an org is deleted, it cascades into Stripe (cancel subscription), Svix (delete app), sandboxes (stop running tasks), and DB (cascade rows). Skipping the helper means orphaned billing or webhooks.
Default RBAC is owner / admin / member. Don't reach for custom roles or dynamic access control until the product genuinely needs them. The plugin has the surface area for both, but most products don't use it and adding it speculatively just makes the auth surface harder to reason about.
Use this skill when
Adding org-scoped features, managing members/invitations, defining custom roles or RBAC, setting up teams, designing per-org integration boundaries, or debugging owner/admin/member behavior.
Where this repo customizes Better Auth's org plugin
- Auto-personal-org on signup —
lib/auth.tsdatabaseHooks.user.create.afterinserts anorganizationrow and amemberrow with roleownerfor every new user. Done with raw Drizzle (not the plugin API) because hooks don't have a session context. - Invitation emails —
sendInvitationEmailwired throughlib/email.ts+orgInvitationEmailtemplate. - Org lifecycle cleanup —
lib/org-lifecycle.tsexportsdeleteOrganization()(Stripe + Svix + sandbox + DB cascade) andcancelOrgSubscription(). - Access control config —
lib/org-access.tsdefines role/resource permissions for fork customization.
Active organization pattern
The active org is stored in the session. Set it after the user picks one:
import { organization } from "@/lib/auth-client"
await organization.setActive({ organizationId })
Many endpoints (listMembers, inviteMember, listInvitations) use the active org when organizationId isn't passed. Use organization.getFullOrganization() to retrieve the active org with members/invitations/teams attached.
Inviting members
import { organization } from "@/lib/auth-client"
// Standard email invite — uses sendInvitationEmail
await organization.inviteMember({ email: "new@example.com", role: "member" })
// Shareable URL — does NOT call sendInvitationEmail; you handle delivery
const { data } = await organization.getInvitationURL({
email: "new@example.com",
role: "member",
callbackURL: "https://yourapp.com/dashboard",
})
Invitations expire in 48h by default; configurable via invitationExpiresIn.
Permission checks
const { data } = await organization.hasPermission({ permission: "member:write" })
if (data?.hasPermission) { /* allowed */ }
checkRolePermission({ role, permissions }) is for static client UI rendering only. For real authorization, use hasPermission (server-side check).
Hard rules
- Never delete an org via raw
db.delete. UsedeleteOrganization()fromlib/org-lifecycle.ts. Skipping means leaked Stripe subs and orphaned Svix apps. - Never remove the last owner. Better Auth refuses, but check before you try — transfer ownership first via
updateMemberRole. - Never store tenant data without
organizationId. Every new table that holds product data should be org-scoped from day one. - Never assume org membership in route handlers.
withApiKeyAuthresolves the org from the API key, not from anywhere else. If you need to verify a user belongs to an org, checkmember. - Don't enable teams or dynamic access control speculatively. They're real features with real maintenance cost. Add them when the product needs them.
Owner protection
The plugin enforces:
- Last owner cannot be removed
- Last owner cannot leave
- Owner role cannot be removed from the last owner
Always transfer ownership before demoting:
// 1. Promote new owner first
await organization.updateMemberRole({ memberId: "new-owner", role: "owner" })
// 2. Then demote the previous owner
await organization.updateMemberRole({ memberId: "old-owner", role: "admin" })
Where things live
| File | Purpose |
|---|---|
lib/auth.ts | Better Auth config — organization() plugin + auto-personal-org hook |
lib/auth-client.ts | Exports organization, useActiveOrganization, useListOrganizations |
lib/org-access.ts | Role/resource permissions — fork customization point |
lib/org-lifecycle.ts | deleteOrganization, cancelOrgSubscription — full cascade |
lib/schema.ts | organization, member, invitation tables + product tables with organizationId |
app/(dashboard)/members/ | Members management page |
scripts/migrate-users-to-orgs.ts | One-time migration helper for pre-org data |
Auxiliary content
- references/original-guide.md — full Better Auth org plugin reference: hooks, schema customization, dynamic access control, teams, complete config example
- references/workflow.md — repo-specific org workflow
- references/graph.md — handoff to
better-auth-best-practices,db-health,add-api-endpoint - scripts/list-org-surfaces.sh — enumerates org-scoped tables, role checks, and lifecycle hooks; run when auditing tenant boundaries
- assets/rbac-matrix-template.md — fill-in matrix for designing per-product RBAC (resources × roles)