Organizations / Teams
Organizations / Teams
What it does
Multi-tenant workspaces on top of better-auth's organization plugin: any
user can create an organization, invite members by email with a role
(owner/admin/member), and switch between their personal account and any
org they belong to. There's no teams layer (nested sub-groups within an org)
— just flat orgs with roles. Turn that on later via the teams plugin option
if a project needs it.
How to configure it
No env vars specific to this feature — it rides on the same
RESEND_API_KEY/EMAIL_FROM as the rest of transactional email (see
email.md) for invitation emails, and the same DATABASE_URL.
Tunables live in lib/auth.ts's organization({...}) call:
| Option | Current value | What it controls |
|---|---|---|
allowUserToCreateOrganization | true | Can be a function for plan-gating who may create an org |
creatorRole | "owner" | Role auto-assigned to whoever creates the org |
invitationExpiresIn | 60 * 60 * 48 (seconds) | How long an invite link is valid |
sendInvitationEmail | see below | Wires the invite email through lib/email.ts |
How it works
lib/auth.ts— theorganizationplugin.sendInvitationEmailbuilds${appUrl}/accept-invitation/{invitation.id}and sendsemails/invite-member.tsxthrough the sharedsendEmail()helper (critical: true, same as the other auth emails — an unconfiguredRESEND_API_KEYlogs and no-ops rather than failing the invite call).lib/auth-client.ts—organizationClient(), exportingauthClient.organization.*(create, update, delete, invite, accept/reject invitation, list/remove/update-member-role, leave, setActive, list) plus theuseActiveOrganization/useListOrganizationshooks. Method names are derived from the server's endpoint paths —/organization/invite-member→inviteMember,/organization/update-member-role→updateMemberRole, etc. — so checknode_modules/better-auth/dist/plugins/organization/routes/*.mjsfor the full list rather than guessing.lib/db/schema/auth.ts—organization,member,invitationtables, plussession.activeOrganizationId(a plugin-added column: which org a session is currently "in";nullmeans personal account). These are hand-written to match the plugin's own field names, not generated.@better-auth/cli generatecan't introspect this repo: it loadslib/auth.ts, which reacheslib/env.ts, which importsserver-only— unresolvable outside Next's bundler. Any plugin that adds tables has to be mirrored into the schema by hand.lib/dal.ts—requireOrg():verifySession()+auth.api.getFullOrganization()(which itself defaults tosession.activeOrganizationId), redirecting to/settings/organizationif there's no active org or the caller isn't a member. Use this instead ofverifySession()for any future page/action whose data should be scoped byorgId.app/(app)/settings/organization/page.tsx— the management UI: switcher, profile, members, invites, danger zone. All mutations are plainauthClient.organization.*calls from client components incomponents/organizations/, each followed byrouter.refresh()to re-pull server data — no Server Actions, matching howprofile-form.tsxalready does simple mutations in this starter.app/(app)/layout.tsx— sameOrgSwitcherin the sidebar, fed byauth.api.listOrganizations().app/(auth)/accept-invitation/[id]/page.tsx— server component. Redirects to/login?redirect=/accept-invitation/{id}if logged out;login/signupboth honor?redirect=(previously hardcoded to/dashboard) so the user lands back on the invite after authenticating. Renders invite details viaauth.api.getInvitation()(throws if the invited email doesn't match the logged-in user, or if the invite is expired/already used — both surfaced as a plain error state, not a crash), thenAcceptInvitationActions(Accept/Decline) callsauthClient.organization.acceptInvitation(), which sets the org active automatically.
Roles and permissions
Three built-in roles: owner (full control, including delete-org — enforced
by the server, not just hidden in the UI), admin (manage members/invites,
edit org profile), member (read-only). The UI hides management controls for
non-owner/admin members, but every mutating endpoint re-checks the role
server-side — the server also refuses to demote or remove the last owner
regardless of what the UI allows.
How to extend it
- Scope a feature by org: add
organizationIdto its table, callrequireOrg()instead ofverifySession(), filter queries byorg.id. - Custom roles/permissions: pass
ac/rolesto theorganization()config — see thedynamicAccessControlandrolesoptions innode_modules/better-auth/dist/plugins/organization/types.d.mts. - Teams (nested groups within an org): set
teams: { enabled: true }inlib/auth.ts, then redo the schema-generation step by hand (addteam/teamMembertables) the same wayorganization/member/invitationwere added — see the schema note above for why the CLI can't generate them here. - Org logos:
org-profile-form.tsxhas a placeholder avatar; wire it to the presigned upload flow in uploads.md, thenPUTthe returned URL viaauthClient.organization.update({data: {logo}}).
How to remove it
Delete the organization plugin call from lib/auth.ts and
organizationClient() from lib/auth-client.ts, drop requireOrg() from
lib/dal.ts, delete components/organizations/,
app/(app)/settings/organization/, app/(auth)/accept-invitation/, and the
organization/member/invitation tables + session.activeOrganizationId
from lib/db/schema/auth.ts (generate a migration to drop them). Remove the
redirect query-param handling from login/signup if nothing else needs
it, and the OrgSwitcher import from app/(app)/layout.tsx.