Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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:

OptionCurrent valueWhat it controls
allowUserToCreateOrganizationtrueCan be a function for plan-gating who may create an org
creatorRole"owner"Role auto-assigned to whoever creates the org
invitationExpiresIn60 * 60 * 48 (seconds)How long an invite link is valid
sendInvitationEmailsee belowWires the invite email through lib/email.ts

How it works

  • lib/auth.ts — the organization plugin. sendInvitationEmail builds ${appUrl}/accept-invitation/{invitation.id} and sends emails/invite-member.tsx through the shared sendEmail() helper (critical: true, same as the other auth emails — an unconfigured RESEND_API_KEY logs and no-ops rather than failing the invite call).
  • lib/auth-client.ts — organizationClient(), exporting authClient.organization.* (create, update, delete, invite, accept/reject invitation, list/remove/update-member-role, leave, setActive, list) plus the useActiveOrganization/useListOrganizations hooks. Method names are derived from the server's endpoint paths — /organization/invite-member → inviteMember, /organization/update-member-role → updateMemberRole, etc. — so check node_modules/better-auth/dist/plugins/organization/routes/*.mjs for the full list rather than guessing.
  • lib/db/schema/auth.ts — organization, member, invitation tables, plus session.activeOrganizationId (a plugin-added column: which org a session is currently "in"; null means personal account). These are hand-written to match the plugin's own field names, not generated. @better-auth/cli generate can't introspect this repo: it loads lib/auth.ts, which reaches lib/env.ts, which imports server-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 to session.activeOrganizationId), redirecting to /settings/organization if there's no active org or the caller isn't a member. Use this instead of verifySession() for any future page/action whose data should be scoped by orgId.
  • app/(app)/settings/organization/page.tsx — the management UI: switcher, profile, members, invites, danger zone. All mutations are plain authClient.organization.* calls from client components in components/organizations/, each followed by router.refresh() to re-pull server data — no Server Actions, matching how profile-form.tsx already does simple mutations in this starter.
  • app/(app)/layout.tsx — same OrgSwitcher in the sidebar, fed by auth.api.listOrganizations().
  • app/(auth)/accept-invitation/[id]/page.tsx — server component. Redirects to /login?redirect=/accept-invitation/{id} if logged out; login/signup both honor ?redirect= (previously hardcoded to /dashboard) so the user lands back on the invite after authenticating. Renders invite details via auth.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), then AcceptInvitationActions (Accept/Decline) calls authClient.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 organizationId to its table, call requireOrg() instead of verifySession(), filter queries by org.id.
  • Custom roles/permissions: pass ac/roles to the organization() config — see the dynamicAccessControl and roles options in node_modules/better-auth/dist/plugins/organization/types.d.mts.
  • Teams (nested groups within an org): set teams: { enabled: true } in lib/auth.ts, then redo the schema-generation step by hand (add team/ teamMember tables) the same way organization/member/invitation were added — see the schema note above for why the CLI can't generate them here.
  • Org logos: org-profile-form.tsx has a placeholder avatar; wire it to the presigned upload flow in uploads.md, then PUT the returned URL via authClient.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.