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 workspace | Team workspace | |
|---|---|---|
| Billing subject | The user | The organization |
| Balance table | credit_balances | organization_credit_balances |
| Subscription row | organization_id is NULL | organization_id points at the org |
| Can buy credit packs | Yes | No (credit packs are personal assets) |
| Seat cap | N/A | The 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:
| Table | Description |
|---|---|
organization | id / name / slug (unique) / logo / metadata (JSON text serialized by the plugin, never queried by key) |
member | Membership. (organization_id, user_id) is composite unique — one user has exactly one membership row per organization |
invitation | Invitations. 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 unattributedRESTRICT 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 workspaceEvery 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.


Roles
Plugin roles are plain strings (comma-separated when multiple). The boilerplate uses three:
| Role | Source | Permissions |
|---|---|---|
owner | Automatic for whoever creates the org (creatorRole: 'owner') | Subscribe, invite, change roles, remove members, delete the organization |
admin | Assigned by the owner | Invite, manage members |
member | The default role for an invited join | Use 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:
{
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: { /* ... */ },
}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/teamConfiguration (lib/auth/organization.ts):
| Setting | Value | Meaning |
|---|---|---|
invitationExpiresIn | 48 hours | Invitation lifetime |
cancelPendingInvitationsOnReInvite | true | Re-inviting the same email voids the previous invitation |
organizationLimit | 5 | A user may create at most 5 organizations (creating is free and entitlements come from the subscription, so this is only an abuse backstop) |
membershipLimit | 100 | The 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:
| Hook | Path it covers | Counts pending invitations |
|---|---|---|
beforeCreateInvitation | Sending an invitation | Yes — outstanding invitations reserve seats |
beforeAcceptInvitation | Invited join (the main path) | No — the accepter consumes the seat its own invitation reserved |
beforeAddMember | Creating an org / server-side addMember | No |
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 insertedThe 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:
- 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
- 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:
| Card | Contents |
|---|---|
| Subscription | Current team plan, status, period. Links to /dashboard/billing |
| Shared credit pool | The two bucket balances |
| Members and seats | Member list (change role, remove, leave) + a three-state invite area + pending invitations |
| Activity | Paginated 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 ledgerUsing 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:
- The foreign key delete behavior (
cascade,RESTRICTorset null) - The subject predicate on reads (
organizationId ? eq(org) : and(eq(user), isNull(org))) - 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).