Menu

Teams and Organizations

Good to know

Teams are new in v4.0.0. The feature spans both authorization (the Better Auth organization plugin) and billing (the organization as a billing subject); this page covers both sides.

The core idea: the organization is a billing subject

In one sentence: an organization is not just a group of members — it is a billing subject that can buy a subscription and hold credits.

Personal workspaceTeam workspace
Billing subjectThe userThe organization
Balance tablecredit_balancesorganization_credit_balances
Subscription roworganization_id is NULLorganization_id points at the org
Can buy credit packsYesNo (credit packs are personal assets)
Seat capN/AThe team plan's seats, or 1 without a subscription

Both share the same bucket shape, the same idempotency mechanics and the same ledger table; only the balance table differs. Business code rarely needs a branch for teams.

The data model

Three tables come from Better Auth's organization plugin:

TableDescription
organizationid / name / slug (unique) / logo / metadata (JSON text serialized by the plugin, never queried by key)
memberMembership. (organization_id, user_id) is composite unique — one user has exactly one membership row per organization
invitationInvitations. status is pending / accepted / rejected / canceled; there is no persisted expired status — expiry is judged from expires_at at read time

How they relate to the billing tables:

organization
    ├─ organization_credit_balances   (cascade)  the pool goes with the org
    ├─ subscriptions.organization_id  (RESTRICT) ever subscribed → no hard delete
    ├─ credit_transactions.organization_id (RESTRICT) ever moved credits → no hard delete
    └─ orders.organization_id         (set null) orders survive, just unattributed

RESTRICT is the database-level last line of defense; the application layer has its own deletion guard (below).

Workspaces: who the current request acts as

session.activeOrganizationId decides who the current request is attributed to. The single server-side exit is getWorkspaceContext():

import { getWorkspaceContext } from '@/lib/auth/server'
 
const workspace = await getWorkspaceContext()
// { userId: string, organizationId: string | null } | null
// organizationId null = personal workspace

Every call re-checks membership against the member table. That step is not optional: once a user is removed from an organization or the organization is deleted, the activeOrganizationId in the session becomes a stale pointer. The re-check makes it silently degrade back to the personal workspace instead of spending someone else's pool.

The frontend switches workspaces with components/shared/WorkspaceSwitcher.tsx, which calls organization.setActive to write the session; every server-side consumer (billing pool, balance display) switches with it.

workspace-switcher
workspace-switcher

Roles

Plugin roles are plain strings (comma-separated when multiple). The boilerplate uses three:

RoleSourcePermissions
ownerAutomatic for whoever creates the org (creatorRole: 'owner')Subscribe, invite, change roles, remove members, delete the organization
adminAssigned by the ownerInvite, manage members
memberThe default role for an invited joinUse the shared credit pool

Keep these apart: this is the role inside an organization, entirely separate from user.role (user / admin / superadmin), which governs site-wide admin access.

Team subscriptions and seats

Defining a team plan

Mark it with audience: 'team' in config/pricing.ts and declare the seat count:

config/pricing.ts
{
  id: 'team-monthly',
  kind: 'subscription',
  interval: 'month',
  audience: 'team',       // The billing subject is an organization
  seats: 5,               // Seat cap
  monthlyCredits: 10000,  // Granted into the shared pool
  provider: 'stripe',
  stripePriceId: { test: '...', live: '...' },
  price: 149.5,
  currency: 'USD',
  active: true,
  copy: { /* ... */ },
}

See Pricing Configuration.

How the seat cap is computed

seats = getPlanSeats(planId of the live team subscription)
  • With a live subscription → that plan's seats
  • Without one → 1 (the owner alone)

"No subscription means one seat" removes a pile of special cases: no call site needs an explicit "refuse invitations without a subscription" check, because the cap is 1 and the owner already fills it.

"Live" uses the ENTITLED set (active / trialing / past_due / unpaid). Dunning does not cost seats — members are not kicked and the cap does not shrink. This is the same yardstick used by credits and storage retention, which keeps the UI display and server-side enforcement permanently in agreement.

Whose customer does a team subscription hang on

A team subscription hangs off the purchaser's personal Stripe customer; there is no organization-level customer. The only thing routing credits into the org pool is the organizationId written into checkout metadata.

That leads to one server-enforced restriction: only the purchaser may open the self-service portal. Otherwise any member holding a session could see the card on file or even cancel the subscription. The check lives on the server, not in a hidden UI button.

The invitation flow

An owner/admin enters an email on /dashboard/team


beforeCreateInvitation hook       ← seat pre-check (pending invitations counted)


Write the invitation row + send the email   emails/organization-invitation.tsx


The recipient opens /accept-invitation/{id}

        ├─ Signed out → /login?next=... and back
        ├─ Email mismatch → a notice plus a "sign out and switch" exit
        └─ Already settled or invalid → read-only view


Accept


beforeAcceptInvitation hook       ← seat pre-check (pending NOT counted; this one is being consumed)


Write the member row


afterAcceptInvitation hook        ← concurrency compensation: reclaim if over cap


Switch the active workspace → /dashboard/team

Configuration (lib/auth/organization.ts):

SettingValueMeaning
invitationExpiresIn48 hoursInvitation lifetime
cancelPendingInvitationsOnReInvitetrueRe-inviting the same email voids the previous invitation
organizationLimit5A user may create at most 5 organizations (creating is free and entitlements come from the subscription, so this is only an abuse backstop)
membershipLimit100The plugin-level safety net; the real cap lives in the hooks and follows the subscription

Invitation emails go through lib/mail with DEFAULT_ADMIN_EMAIL as the sender (an unset value throws outright).

Seat enforcement: two lines of defense

Seat oversell is the hardest part of this module, and the boilerplate uses two layers.

Line one: four pre-hooks

Why four hooks instead of two? Because better-auth 1.4.7's accept-invitation route calls adapter.createMember directly and only fires before/afterAcceptInvitation, never before/afterAddMember. The latter pair only covers createOrganization and the server-side addMember endpoint.

So both pairs must be hung:

HookPath it coversCounts pending invitations
beforeCreateInvitationSending an invitationYes — outstanding invitations reserve seats
beforeAcceptInvitationInvited join (the main path)No — the accepter consumes the seat its own invitation reserved
beforeAddMemberCreating an org / server-side addMemberNo

Line two: an advisory lock plus over-cap reclamation

The pre-hooks are lock-free count-then-act. Two people accepting at the same moment can both pass and push the member count past the paid seats.

And the plugin's member insert is not inside our transaction, so no single lock can cover "check + insert". The fix is to converge after the fact:

afterAcceptInvitation / afterAddMember


pg_advisory_xact_lock(org)        ← serialize


Recount members

        ├─ Not over cap → do nothing
        └─ Over cap → order by (createdAt, id), take the newest N over-cap rows,
                      and delete only the row THIS call inserted

The deterministic ordering guarantees that in a concurrent double-overshoot each intruder evicts exactly itself and never an existing member.

One implementation detail matters: the delete and the throw must be separated. Deleting and then throwing inside one transaction rolls the delete back — the member stays, yet the user sees an error. The transaction is only responsible for "delete the row and compute the seat count"; the throw happens after it commits.

The shared credit pool

Team plan credits land in organization_credit_balances. Buckets, ledger and idempotency are identical to the personal path; only the balance table differs.

Spending needs no branch:

import { consumeCredits } from '@/actions/credits'
 
// Reads the active workspace internally; a team workspace spends from the shared pool
await consumeCredits({ amount: 10, note: 'AI image generation' })

The ledger is still the same credit_transactions table, with the subject told apart by whether organization_id is set. On a team row, user_id records the acting member, so the team page can show who spent what.

Credit packs cannot be bought in a team workspace — they land only in the buyer's personal purchased bucket, and letting one through would mean "paid, yet the current workspace balance never moved". Checkout returns TEAM_WORKSPACE_CREDIT_PACK and the client prompts a switch back.

See Credit System.

The organization deletion guard

The beforeDeleteOrganization hook refuses two situations:

  1. A live team subscription exists (dunning included — the provider is still retrying). The shared pool is a paid entitlement, and deleting the subject would leave late subscription webhooks with nowhere to land
  2. Any funding history exists (subscription rows or ledger rows). Financial records must not evaporate with their subject

Only a "clean" organization that never touched money may hard-delete. The schema-level RESTRICT foreign keys are the database backstop of the same rule.

If you genuinely need to archive an organization with history, that has to be a manual process — the boilerplate deliberately offers no bypass.

The team management page

/dashboard/team, four cards:

CardContents
SubscriptionCurrent team plan, status, period. Links to /dashboard/billing
Shared credit poolThe two bucket balances
Members and seatsMember list (change role, remove, leave) + a three-state invite area + pending invitations
ActivityPaginated organization credit ledger

"Three-state invite area" means: no subscription → an upgrade prompt; seats full → an inline notice; otherwise → the invite form. All three are decided at render time rather than letting users submit into a hook and eat a 403.

Without a team, the page runs a create/select onboarding flow (?create=1 forces the create view).

The data layer is actions/team/index.ts, and every read is guarded by "the caller is a member of this organization":

getTeamOverview(organizationId)      // Shared pool + subscription + member count + pending invitations + seat cap
getTeamCreditHistory({ organizationId, pageIndex, pageSize })  // Paginated org ledger

Using teams in your own features

Most of the time you do nothing — consumeCredits already picks the pool from the workspace. When you do need an explicit branch:

import { getWorkspaceContext } from '@/lib/auth/server'
 
const workspace = await getWorkspaceContext()
 
if (workspace?.organizationId) {
  // Team workspace: the data belongs to the organization
} else {
  // Personal workspace
}

If one of your own tables should support teams, follow what the billing tables do — add an organization_id column and settle three questions:

  1. The foreign key delete behavior (cascade, RESTRICT or set null)
  2. The subject predicate on reads (organizationId ? eq(org) : and(eq(user), isNull(org)))
  3. Membership authorization

FAQ

Q: Can a user belong to several teams?

Yes. The unique constraint says "one membership row per user per organization"; it does not limit how many organizations they join. The one in effect at any moment is whichever activeOrganizationId points at.

Q: Does creating a team cost anything?

No. Creating is free (up to 5), but an organization without a team subscription has a seat cap of 1 — it can invite nobody and has no shared credits.

Q: Are members kicked when the subscription lapses?

No. During dunning (past_due / unpaid) seats and members are untouched. Once the subscription truly ends the cap falls back to 1, but existing member rows are not deleted automatically — the org simply cannot invite anyone new, and the shared pool receives no further grants.

Q: Can a team buy a credit pack when the pool runs dry?

No. Credit packs only land in a personal purchased bucket. The only way to top up a team pool is a subscription grant, so more credits means a bigger plan.

Q: Can an owner leave their own organization?

Not in a way that leaves the organization ownerless. Transfer ownership first, or delete the organization outright (only possible with no funding history).