Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Build With SyntaxKit

Organizations

Multi-tenant workspaces, members, and roles.

Last updated on

7 min read

Organizations are SyntaxKit's multi-tenancy primitive. Every billing-relevant entity (subscriptions, chats, AI usage) scopes to one. Each user gets a personal organization automatically on sign-up so single-user accounts feel native, while teams add additional organizations and invite members. The active organization rides on the session, so every server-side request already knows which org context to operate in.

How Membership Flows

Three paths into a membership: personal org on sign-up, accepting an invitation, and manually creating a new org. Each ends with the active organization on the session.

Three paths lead into a membership. Sign-up auto-creates a personal organization with the user as owner. Invited users land on /accept-invitation/[invitationId] and either sign in or sign up before accepting. Crucially, brand-new invitees still get a personal organization first, so they end up in two orgs. A signed-in user can also create additional organizations directly. All three paths converge on the same outcome: the new org becomes the user's active organization on the session, and lastActiveOrganizationId is persisted so it survives reload.

The diagram source lives at apps/docs/diagrams/organization-membership.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.

Package Layout

Org logic lives across three packages: Better Auth owns the models and the org plugin, oRPC owns the procedures and middleware, and the schema is pre-generated. Seeing the split makes "what multi-tenancy costs" immediately obvious.

src/server.tsorganization({...}) plugin: roles, hooks, invitation email
src/hooks.tsafterUserCreate (personal org), beforeSessionCreate / Update (active org), afterOrganizationDelete (logo cleanup)
src/permissions.tsRole and permission statements (owner / admin / member + custom billing resource)
src/router/organization.tsEvery org procedure: create, list, switch, invite, accept, change role, remove, delete
src/middleware/organization.tswithActiveOrganization and withOrganizationAccess
src/guards/last-owner.tsLast-owner protection and self-service blocks
src/lib/billing.tsMember seat-limit checks against the plan entitlement

What An Organization Is

Three core models hold the multi-tenant story.

Organization

The container. Has a name, unique slug, optional logo, JSON metadata (used to mark personal orgs), and a pointer to the current Stripe subscription.

Member

The junction between User and Organization with a role: owner, admin, or member. Unique on (organizationId, userId).

Invitation

Pending or accepted/canceled, with an email and an optional role. Carries an expiresAt (the email template advertises 7 days; Better Auth enforces the actual duration).

The personal-org pattern is just a metadata flag, not a separate model. afterUserCreate creates an org named <user>'s Organization, makes the user the owner, marks it isPersonalOrganization: true in metadata, and sets it as the user's last-active org. Apart from that flag, a personal org is structurally identical to a team org: same fields, same role behavior, same lifecycle.

Roles And Permissions

Three roles, each layered on Better Auth's defaults plus a SyntaxKit-specific billing resource.

RoleWhat they can do
ownerFull org control including delete; full billing access (view + manage).
adminManage members and invitations; full billing access.
memberView billing only. No member or invitation management.

The permission system has two layers worth knowing about.

Server-side enforcement

Via the withPermission(...) oRPC middleware, which calls auth.api.hasPermission against the role definitions in packages/auth/src/permissions.ts. Every org-mutating procedure goes through it.

Client-side UX gating

Via authClient.organization.checkRolePermission. Used to hide buttons and forms the user can't act on (members don't see "Invite member"). Server enforcement is the source of truth; the UX gate is courtesy.

The Active Organization

Each session carries an activeOrganizationId. The beforeSessionCreate hook populates it: prefer the user's lastActiveOrganizationId if they're still a member there, else fall back to the first organization they belong to ordered by createdAt. When the user switches via organization.setActive, beforeSessionUpdate writes the new id back to User.lastActiveOrganizationId so the next session picks it up.

On the server, two middlewares own the read side.

withActiveOrganization

Loads the active org via auth.api.getFullOrganization({ headers }) and adds it to the procedure context. Use it for procedures that operate implicitly on the active org.

withOrganizationAccess

Does the same and also enforces that the procedure's input.organizationId matches the active org. Use it for procedures that take an org id explicitly. It's the guardrail that keeps API consumers from operating on someone else's org by id.

In the browser, switching is a real UX event. The org switcher in the dashboard sidebar calls organization.setActive, then invalidates user.getSession, organization.list, organization.getActive, the billing queries, and dashboard.getStats, and finally calls router.refresh() so server components re-render with the new context. Mirror this pattern in any feature that caches per-org data.

Inviting And Joining Members

The invitation lifecycle has four procedures.

Invite

organization.inviteMember is gated by withPermission({ member: ["create"] }) and checks the seat limit before delegating to Better Auth's auth.api.createInvitation. The org plugin's sendInvitationEmail callback applies abuse throttling on both inviter and invitee, then sends the templated email pointing at the accept page (/accept-invitation/[invitationId]).

Accept

Logged-in users on /accept-invitation/[invitationId] trigger auth.api.acceptInvitation directly. Logged-out users see a sign-in or sign-up prompt with the invitation id baked into the path; on success, the same accept call runs.

List

organization.listInvitations is gated by withPermission({ invitation: ["read"] }) so ordinary members can see the pending invitation list (it shows up in the org settings UI) without being granted the destructive cancel verb.

Cancel

organization.cancelInvitation flips status to canceled. Available to anyone with invitation: ["cancel"] permission (admins and owners).

A brand-new invitee still gets a personal organization on sign-up before they accept the invitation. They will end up in two organizations: their personal one and the one they were invited to.

Member Seat Limits

assertWithinMemberLimit counts current Member rows plus pending invitations against the plan's maxMembers. The Free plan caps at 3; Pro is unlimited. The check fires inside inviteMember only; once a seat is consumed (member or pending invite), it counts. See the Billing page for the plan catalog and how seat configuration is wired up.

Guards That Keep Things Sane

Three invariants the API enforces beyond Better Auth's defaults.

Last-owner protection

ensureNotLastOwner runs inside removeMember and updateMemberRole (when demoting an owner) using a FOR UPDATE row lock. Removing or demoting the only remaining owner returns a CONFLICT.

Role hierarchy on update

Non-owners cannot assign a role at or above their own rank. Admins can't promote members to admin (or owner); only owners can.

Self-service blocks

assertCanManageMember rejects acting on yourself. You can leave an org, but you can't change your own role.

inviteMember does not currently 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 you need stricter behavior (for example, admins shouldn't be able to invite owners), add a matching roleRank check in the invite handler before calling auth.api.createInvitation.

Org-Scoped Data

The models that scope to an organization today:

Subscription

Stripe state per organization. Organization.currentSubscriptionId points at the active one.

Chat

AI conversations. Carries organizationId directly.

Message

Individual messages. Scoped to an org indirectly via Chat.organizationId, with no direct organizationId column.

AiUsageEvent

Quota ledger for AI usage. Carries organizationId directly.

This shape is the working definition of multi-tenancy in SyntaxKit: queries always filter by context.organization.id, and Prisma's onDelete: Cascade rule on each organizationId relation handles the cleanup when an org is deleted.

Adding Org-Scoped Data

When you add a new product feature that 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, plus an index on organizationId for the queries you'll run most often.

Migrate

pnpm db:migrate:dev

Give the migration a clear, descriptive name (add_team_widgets, not update).

Base your procedure on withActiveOrganization

In your oRPC router, base the procedure on authorized.use(withActiveOrganization) so context.organization is always present in the handler. For procedures that take an org id as input (rare; usually you should rely on the active org), chain withOrganizationAccessByInput to verify the input matches the active org.

Scope every query

In the handler, scope every query by context.organization.id. Never accept organizationId from input unless the procedure also goes through withOrganizationAccess; otherwise consumers can read or write data in orgs they don't belong to.

Mirror the org-switch invalidation

When your feature surfaces in the dashboard, add your TanStack Query keys to the invalidation set in apps/web/components/dashboard/sidebar/organization-switcher.tsx. Without this, switching orgs leaves stale data on the screen.

Deleting An Organization

organization.delete does three things in order.

  1. If the org has an active or trialing Stripe subscription that isn't already scheduled to cancel, cancels it via cancelSubscription. Money first.
  2. Calls auth.api.deleteOrganization, which removes the Organization row. Prisma cascades take care of every Member, Invitation, Chat, Message, AiUsageEvent, and Subscription row.
  3. The afterOrganizationDelete hook in packages/auth/src/hooks.ts removes the org's logo from S3 if storage is configured.

The personal organization is structurally identical to a team org, so it can be deleted by its owner. If you want to prevent that for product reasons, gate organization.delete (or hide the danger form) on the isPersonalOrganization metadata flag. Out of the box, the kit lets owners delete their own personal org.

Where To Go Next

Was this page helpful?

On this page