A studio is a better-auth organization. Its slug is the first URL segment of every studio page, and its organization id is sent to the core API as x-studio-id.

Two layers of membership

The web app reads the first through authClient and the auth server helpers, and the second through performApi at /coaches. They are linked by Coach.externalUserId, with email as a fallback. coaches/page.tsx uses both to find which coach row belongs to the organization owner.

Role mapping

resolveStudioContext converts the organization role before every domain call: Role labels for the organization roles come from useOrganizationMemberRoles() in modules/organizations/hooks/member-roles.ts (organizations.roles.*).

Checking roles in the web app

The web app hides or shows UI by role. Enforcement is the core API’s job. What admins see that members do not:
  • In the sidebar’s settings group: general, billing (when billing is attached to organizations), plans and pricing, notifications, integrations, AutoFit. Members see only team and branding there.
  • settings/integrations and settings/autofit return notFound() for non admins.
  • settings/general shows the feature toggles, MBP settings, WhatsApp contact, SmartSend chat and the delete form only to admins.
  • settings/members shows InviteMemberForm only to admins.

Sub coach permissions

coaches/team-permissions.ts describes the per coach permission blob returned by GET /v1/web/coaches. The keys are trainees, plans, forms, content and subscriptions. elevatedPermissions(coach) returns the keys where a sub coach holds the elevated value: Head coaches and owners bypass the blob, so their permission column stays empty. The team page only displays these. The core API applies them.

The active organization

There are two notions of “active” and they usually agree.
  • By URL. On a studio page, params.organizationSlug decides. ActiveOrganizationProvider queries by slug.
  • By session. On account pages there is no slug, so the provider falls back to session.activeOrganizationId.
Outside the provider the hook returns a safe empty value instead of throwing. setActiveOrganization(slug):
  1. Starts the progress bar.
  2. authClient.organization.setActive({ organizationSlug }).
  3. authClient.updateUser({ lastActiveOrganizationId }).
  4. Patches the cached session.
  5. Refetches the active organization and prefetches its purchases.

Organization selection

(account)/page.tsx decides where a signed in user lands on /:
  • Exactly one studio: redirect straight to /{slug}.
  • Several studios: OrganizationsGrid lists them and AccountStartGuide sits below.
  • None: the start guide only. requireOrganization is off, so nobody is forced to /new-organization.
CreateOrganizationForm (at /new-organization) uses useCreateOrganizationMutation: it asks the backend for a slug with orpcClient.organizations.generateSlug({ name }), then calls authClient.organization.create, then navigates to /{slug}.

Invitations

  • InviteMemberForm validates email and role (member, owner or admin, default member) with Zod.
  • OrganizationInvitationsList shows pending invitations. OrganizationMembersList changes roles with authClient.organization.updateMemberRole and removes members with removeMember.
  • /organization-invitation/[invitationId] loads the invitation on the server with getInvitation(id). A missing invitation redirects to /. The page shows OrganizationInvitationModal with accept and reject. Accept goes to /{organizationSlug}, reject to /.
  • /login and /signup carry ?invitationId= through. LoginForm shows OrganizationInvitationAlert and redirects to the invitation page after sign in. /signup only accepts an invitation that exists, is pending and has not expired.

Team members

The team page (/{slug}/coaches) is the day to day way to add staff. Its actions in coaches/actions.ts call the core API, not better-auth directly: Adding a member past the plan’s seat count is refused by the API with a plan limit reason. TeamMemberDialog opens PlanLimitDialog from it. See Billing.

Organization settings components

Super admin

A user with role === "admin" on the user record is a platform admin. They see /admin/*, count as organization admin everywhere through isOrganizationAdmin, and can impersonate studio owners. Two helpers named adminApi exist: The language switch in the user menu is separately gated by email. canSwitchLanguage(email) in modules/shared/lib/language-switch.ts checks the comma separated config.languageSwitchEmails. See i18n.