Organizations
Multi-tenant workspaces, members, and roles.
Last updated on
7 min readOrganizations 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
The three paths into an org and how they converge on the active organization.
What an organization is
The Organization, Member, and Invitation models behind multi-tenancy.
Roles and permissions
Owner, admin, and member, enforced server-side and gated in the UI.
Add org-scoped data
The walkthrough for making a new feature team-aware.
How Membership Flows
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.
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.
| 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. 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:devGive 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.
- If the org has an active or trialing Stripe subscription that isn't already scheduled to cancel, cancels it via
cancelSubscription. Money first. - Calls
auth.api.deleteOrganization, which removes the Organization row. Prisma cascades take care of every Member, Invitation, Chat, Message, AiUsageEvent, and Subscription row. - The
afterOrganizationDeletehook inpackages/auth/src/hooks.tsremoves 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
Authentication
The Better Auth integration that owns the org plugin and the session shape this page leans on.
Database
The auth.generated.prisma models that back Organization, Member, and Invitation.
API
The org procedures and the withActiveOrganization middleware in context.
Billing
The plan entitlements that define member seat limits and the Stripe state that hangs off the active org.
AI
A worked example of org-scoped data: every chat and usage event belongs to an organization.
