Organizations
Multi-tenancy via Better Auth organizations in packages/auth. Personal orgs on sign-up, invites with owner / admin / member roles, the active org on the session, and how to scope product data to one org.
Last updated on
7 min readOn This Page
How Membership Flows
Three ways to join an organization:
- Sign-up. SyntaxKit creates a personal org and makes the user the owner.
- Invitation. The invitee opens
/accept-invitation/[invitationId], signs in or signs up, then accepts. A brand-new invitee still gets a personal org first, so they belong to two orgs. - Create. A signed-in user can create more orgs at any time.
All three end the same way: the new org becomes the active organization on the session, and lastActiveOrganizationId is saved so it survives a reload.
What An Organization Is
Three models hold the multi-tenant story.
| Model | What it holds |
|---|---|
Organization | Name, unique slug, optional logo, JSON metadata (used to mark personal orgs), and a pointer to the current Stripe subscription. |
Member | Links a User to an Organization with a role of owner, admin, or member. Unique on (organizationId, userId). |
Invitation | An email, an optional role, and a status of pending, accepted, or canceled. Carries an expiresAt. The email says 7 days; Better Auth enforces the real duration. |
A personal org is not a separate model. It is a normal org with isPersonalOrganization: true in metadata. afterUserCreate builds it as <user>'s Organization, makes the user the owner, and sets it as last-active. Same fields, same roles, same lifecycle as a team org.
Where the code lives
Org logic spans three packages. Better Auth owns the models and the org plugin. oRPC owns the procedures and middleware. The Prisma schema is generated from the auth config.
Roles And Permissions
Three roles, built on Better Auth's defaults plus a SyntaxKit billing resource.
| Role | What they can do |
|---|---|
owner | Full org control including delete. Full billing access (view + manage). |
admin | Manage members and invitations. Full billing access. |
member | View billing only. Cannot manage members or invitations. |
Permissions are checked in two layers.
| Layer | How it works |
|---|---|
| Server | withPermission(...) calls auth.api.hasPermission using the roles in packages/auth/src/permissions.ts. Every org-mutating procedure goes through it. |
| Client | authClient.organization.checkRolePermission hides buttons the user cannot use (for example, members never see "Invite member"). The server is the source of truth; the UI gate is courtesy. |
Guards beyond the role check
Three extra rules on top of Better Auth's defaults.
| Guard | What it enforces |
|---|---|
| Last-owner protection | ensureNotLastOwner runs inside removeMember and updateMemberRole (when demoting an owner), behind a FOR UPDATE row lock. Removing or demoting the only owner returns a CONFLICT. |
| Role hierarchy on update | Non-owners cannot assign a role at or above their own. Admins cannot promote anyone to admin or owner. |
| Self-service blocks | assertCanManageMember rejects acting on yourself. You can leave an org, but you cannot change your own role. |
inviteMember does not re-apply the role-hierarchy check that updateMemberRole enforces. Better Auth's default permission gate is the only block on which roles can be invited. If admins should not invite owners, add a matching roleRank check in the invite handler before auth.api.createInvitation.
The Active Organization
Every session has an activeOrganizationId. That id is how the kit knows which team you are working in right now.
How it gets set. On session create, beforeSessionCreate prefers User.lastActiveOrganizationId if the user is still a member there. Otherwise it falls back to the user's first org by createdAt. When the user switches with organization.setActive, beforeSessionUpdate writes the new id back to lastActiveOrganizationId so the next session picks it up.
How the API reads it. Two middlewares cover the common cases.
| Middleware | When to use it |
|---|---|
withActiveOrganization | Loads the active org via auth.api.getFullOrganization({ headers }) and puts it on context.organization. Use this when the procedure works on the active org implicitly. |
withOrganizationAccess | Same load, plus a check that input.organizationId equals the active org. Use this when the procedure takes an org id in the input. It stops callers from targeting another org by id. |
How the browser switches. The dashboard org switcher calls organization.setActive, invalidates user.getSession, organization.list, organization.getActive, the billing queries, and dashboard.getStats, then calls router.refresh() so server components re-render. Mirror that invalidation set in any feature that caches per-org data.
Inviting And Joining Members
The invitation lifecycle has four stages.
| Stage | What happens |
|---|---|
| Invite | organization.inviteMember requires withPermission({ member: ["create"] }), checks the seat limit, then calls auth.api.createInvitation. The org plugin's sendInvitationEmail throttles abuse on inviter and invitee, then emails a link to /accept-invitation/[invitationId]. |
| Accept | Logged-in users on that page call auth.api.acceptInvitation directly. Logged-out users sign in or sign up with the invitation id in the path; the same accept call runs after. |
| List | organization.listInvitations requires withPermission({ invitation: ["read"] }). Members can see pending invites without the cancel permission. |
| Cancel | organization.cancelInvitation sets status to canceled. Available to anyone with invitation: ["cancel"] (admins and owners). |
Seat limits
assertWithinMemberLimit counts current Member rows plus pending invitations against the plan's maxMembers. Free caps at 3; Pro is unlimited. The check runs inside inviteMember only, so a seat is reserved as soon as the invitation exists. See Billing for the plan catalog.
Org-Scoped Data
These models scope to an organization today:
| Model | How it scopes |
|---|---|
Subscription | Stripe state per organization. Organization.currentSubscriptionId points at the active one. |
Chat | AI conversations. Carries organizationId directly. |
Message | Scoped through Chat.organizationId. No direct organizationId column. |
AiUsageEvent | AI quota ledger. Carries organizationId directly. |
That is the multi-tenancy rule in SyntaxKit: always filter by context.organization.id. Prisma's onDelete: Cascade on each organizationId relation cleans up the rows when an org is deleted.
Adding Org-Scoped Data
When a feature should be team-aware, follow the same shape every existing org-scoped model uses.
Add the relation to your model
Edit the relevant file in packages/database/prisma/models/. Add organizationId String and an Organization relation with onDelete: Cascade. Index organizationId for the queries you run most often.
Base your procedure on withActiveOrganization
In your oRPC router, use authorized.use(withActiveOrganization) so context.organization is always set. If the procedure also takes organizationId as input (rare; prefer the active org), chain withOrganizationAccess so the input must match the active org.
Scope every query
Filter every query by context.organization.id. Do not accept organizationId from input unless the procedure also uses withOrganizationAccess. Without that guard, callers can read or write data in orgs they should not touch.
Mirror the org-switch invalidation
If the feature shows in the dashboard, add your TanStack Query keys to the invalidation set in apps/web/components/dashboard/sidebar/organization-switcher.tsx. Without that, switching orgs leaves stale data on screen.
Deleting An Organization
organization.delete does three things in order.
- Cancels the org's Stripe subscription via
cancelSubscriptionwhen it is active or trialing and not already scheduled to cancel. Billing stops before the data goes away. - Calls
auth.api.deleteOrganization, which removes theOrganizationrow. Prisma cascades delete every relatedMember,Invitation,Chat,Message,AiUsageEvent, andSubscriptionrow. - Runs
afterOrganizationDeleteinpackages/auth/src/hooks.ts, which removes the org's logo from S3 when storage is configured.
A personal org is structurally identical to a team org, so its owner can delete it. To prevent that, gate organization.delete (or hide the danger form) on the isPersonalOrganization metadata flag. Out of the box, the kit allows it.
Where To Go Next
Authentication
Better Auth wiring, the org plugin, and the session shape organizations depend on.
API
The org procedures and the withActiveOrganization middleware in context.
Also useful: Billing for seat limits and plan entitlements, Database for the generated models, and AI as a worked example of org-scoped data.
Authentication
Better Auth in packages/auth for email/password, OAuth, passkeys, and 2FA. Organizations, platform admin, Turnstile, and abuse throttling ship on top. Sessions carry the active organization. Identity models live in auth.generated.prisma.
Billing
Org-scoped Stripe subscriptions with hosted Checkout and Customer Portal. One catalog file defines tiers, prices, features, and limits. Gate access from oRPC with BillingState phases and entitlements.
