Skip to content

Workspaces and teams

A workspace is what a customer’s data belongs to: a company, or one person. In the database and in Better Auth it is an organization. Everything your product stores belongs to a workspace, never directly to a user.

teams in packages/contracts/src/app.ts picks the kind of product. It is false by default.

Teams off (personal accounts) Teams on
Workspaces Exactly one per person, created on their first visit, never shown Created by people, as many as they want
Other people None: invitations are refused with Teams are turned off Invited by email, with a role
Addresses /app/settings/billing /w/acme/settings/billing
Pricing Flat Flat or per seat

With teams off the API creates the workspace in WorkspacesService when /api/me is first called, with the slug user-<userId>, and the person is its owner. The web app still routes through /w/$slug; a rewrite in apps/web/src/lib/personal-urls.ts shows it as /app, with me standing in for the slug. That is why one set of screens serves both modes, and why links must be written as /w/$slug/..., never as /app.

You can turn teams on later. Data already belongs to workspaces, so nothing moves: each person’s personal workspace becomes a workspace they own, at /w/user-<id>/..., which they can rename and invite people to. Links saved under /app stop working. Turning teams off again is not supported once people share workspaces: there is no way to decide whose a shared workspace becomes.

Two layers, and both are needed.

The guard decides who reaches the route. A controller marked @WorkspaceScoped() takes the workspace from :workspaceId in the URL and looks up the caller’s membership. A caller who is not a member gets 404 Workspace not found, exactly as for a workspace that does not exist, so ids cannot be probed. The workspace, the caller’s role and the plan in effect are then available through @CurrentWorkspace(). The id never comes from a request body or query string, where it could be anyone’s.

The query decides which rows come back. The guard cannot see your SQL. Every query on workspace data filters by organizationId, including updates and deletes by id. Without it, a member of one workspace could reach another’s row by guessing its id through their own workspace’s route. Tables of workspace data have organizationId referencing organizations.id with onDelete: 'cascade', so deleting a workspace deletes its data.

AdminService is the one place that reads across workspaces, for the operators’ admin panel.

Roles are ordered owner > admin > member; @RequireRole('admin') lets owners through too. What each can do as the kit ships:

Member Admin Owner
Use the product’s screens ✓ ✓ ✓
See who is in the workspace ✓ ✓ ✓
Invite people, cancel invitations ✓ ✓
Remove people, change roles ✓, not owners ✓
Make someone an owner ✓
Rename the workspace ✓ ✓
Read the audit log (on plans with it) ✓ ✓
Create and revoke API keys ✓ ✓
See and change billing ✓
Delete the workspace ✓

Membership rules come from Better Auth’s organization plugin; billing and the audit log from @RequireRole() on their controllers. A workspace always keeps at least one owner: the last owner cannot leave or be demoted. With teams off, the only role anyone has is owner of their own workspace.

Being an operator of the whole product is separate: it comes from ADMIN_EMAILS, not from any workspace role. See Admin panel.

A plan’s maxSeats caps how many members a workspace can have. Open invitations hold a seat, so a workspace cannot invite past its limit and then accept them all; inviting the same address again replaces the old invitation rather than taking a second seat. On per-seat plans, each change of membership updates the quantity with the payment provider. See Billing.