Organization
Add multi-tenant organization management with members, invitations, and roles to your authentication UI.
The organization plugin adds multi-tenant organization management to your authentication UI. Users can create, switch between, and manage organizations with members, invitations, and roles.
It contributes:
- An
organizationstab to<Settings />listing every organization the user belongs to plus pending invitations to them - An
<Organization />shell mounted at/organization/<path>withsettingsandpeopletabs - An
<OrganizationSwitcher />dropdown to switch the active organization, manage it, or create a new one - An
organizationCardsplugin slot rendered inside<OrganizationSettings />so other plugins (for example api-key) can attach org-scoped cards - Hooks and mutations for organization endpoints, including
useActiveOrganization,useListOrganizations, anduseInviteMember
Setup
Install the server plugin
Add the organization plugin to your Better Auth server config:
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
export const auth = betterAuth({
// ...
plugins: [
organization()
]
})Install the matching client plugin
Add organizationClient() to your auth client so authClient.organization.* methods are available:
import { createAuthClient } from "better-auth/react"
import { organizationClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [organizationClient()]
})Install the UI plugin
Run the shadcn CLI to install every organization component and the organizationPlugin() factory into your project:
npx shadcn@latest add @better-auth-ui/organizationThis drops the following into your codebase:
src/lib/auth/auth-plugin.ts: localAuthPlugintyping widenersrc/lib/auth/organization-plugin.tsx:organizationPlugin()factorysrc/components/auth/organization/accept-invitation.tsx: direct invitation acceptance viewsrc/components/auth/organization/organization.tsx: the/organization/<path>shellsrc/components/auth/organization/organization-roles.tsx: dynamic role and permission editorsrc/components/auth/organization/organization-switcher.tsx: header dropdown for switching organizationssrc/components/auth/organization/team-switcher.tsx: organization-scoped team selectionsrc/components/auth/organization/organization-settings.tsx: settings tab contents (profile + danger zone + plugin cards)src/components/auth/organization/organization-people.tsx: people tab contents (members + invitations)src/components/auth/organization/organization-profile.tsx: profile card (logo, name, slug)src/components/auth/organization/organization-danger-zone.tsx: danger zone cardsrc/components/auth/organization/organization-members.tsx: members table with search/filter/sortsrc/components/auth/organization/organization-member-row.tsx: member row with role and remove actionssrc/components/auth/organization/organization-invitations.tsx: invitations table with search/filter/sortsrc/components/auth/organization/organization-invitation-row.tsx: invitation row with cancel actionsrc/components/auth/organization/organizations.tsx: list of organizations the user belongs tosrc/components/auth/organization/organization-row.tsx: single organization row in the listsrc/components/auth/organization/organizations-settings.tsx:/settings/organizationspanelsrc/components/auth/organization/user-invitations.tsx: invitations addressed to the usersrc/components/auth/organization/user-invitation-row.tsx: invitation row with accept/reject actionssrc/components/auth/organization/create-organization-dialog.tsx: new-organization dialogsrc/components/auth/organization/invite-member-dialog.tsx: invite-by-email dialogsrc/components/auth/organization/delete-organization-dialog.tsx: delete confirmation dialogsrc/components/auth/organization/leave-organization-dialog.tsx: leave confirmation dialogsrc/components/auth/organization/remove-member-dialog.tsx: remove-member confirmation dialogsrc/components/auth/organization/leave-organization.tsx/delete-organization.tsx: danger-zone rowssrc/components/auth/organization/change-organization-logo.tsx: logo upload controlsrc/components/auth/organization/organization-logo.tsx,organization-view.tsx,slug-field.tsx, plus matching skeletons and empty states
Register the UI plugin
Pass organizationPlugin() to <AuthProvider> so the organizations settings tab, the <Organization /> shell, and <OrganizationSwitcher /> can read plugin localization and view paths:
import { AuthProvider } from "@/components/auth/auth-provider"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { authClient } from "@/lib/auth-client"
<AuthProvider
authClient={authClient}
plugins={[
organizationPlugin()
]}
>
{children}
</AuthProvider>Allow the invitation auth path
The organization plugin registers a direct invitation view at /auth/accept-invitation. Include its auth path in any route allow-list that wraps <Auth path={path} />:
import { viewPaths } from "@better-auth-ui/core"
import { Auth } from "@/components/auth/auth"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
const validAuthPaths = [
...Object.values(viewPaths.auth),
...Object.values(organizationPlugin().viewPaths.auth)
]Invitation links use /auth/accept-invitation?invitationId=<id>. Signed-out recipients return to the invitation after signing in, and successful accept or reject actions continue to the configured redirectTo.
Mount the organization switcher
Add <OrganizationSwitcher /> to the application shell, usually next to <UserButton /> in the header.
It shows the selected organization and provides switch and create actions:
import { UserButton } from "@/components/auth/user/user-button"
import { OrganizationSwitcher } from "@/components/auth/organization/organization-switcher"
export function Header() {
return (
<header className="flex items-center justify-between gap-3 p-4">
<OrganizationSwitcher />
<UserButton />
</header>
)
}Allow the organizations settings path
The plugin contributes an organizations segment to viewPaths.settings. Spread organizationPlugin().viewPaths.settings into your settings route's allowed-path set so /settings/organizations resolves correctly:
import { viewPaths } from "@better-auth-ui/core"
import { ensureSession } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-start/server"
import { Settings } from "@/components/auth/settings/settings"
import { auth } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
const validSettingsPaths = [
...Object.values(viewPaths.settings),
...Object.values(organizationPlugin().viewPaths.settings)
]
export const Route = createFileRoute("/settings/$path")({
async beforeLoad({ params: { path }, context: { queryClient }, location }) {
if (!validSettingsPaths.includes(path)) {
throw notFound()
}
const ensureSessionIso = createIsomorphicFn()
.server(() =>
ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
)
.client(() => ensureSession(queryClient, authClient))
const session = await ensureSessionIso()
if (!session) {
throw redirect({
to: "/auth/$path",
params: { path: "sign-in" },
search: { redirectTo: location.href }
})
}
return { session }
},
component: SettingsPage
})
function SettingsPage() {
const { path } = Route.useParams()
return (
<div className="w-full max-w-3xl mx-auto p-4 md:p-6">
<Settings path={path} />
</div>
)
}/settings/organizations now renders <OrganizationsSettings />: the list of organizations the user belongs to plus pending invitations.
Create the organization page
Mount a dynamic route at /organization/<path> that renders <Organization /> for the matching tab. <Organization /> is the shell: it shows the settings (profile + danger zone) and people (members + invitations) tabs for the active organization. The active organization is whatever the user picked in <OrganizationSwitcher /> (persisted on the server session via setActive).
import { ensureSession } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-start/server"
import { Organization } from "@/components/auth/organization/organization"
import { auth } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
const validOrganizationPaths = Object.values(
organizationPlugin().viewPaths.organization
)
export const Route = createFileRoute("/organization/$path")({
async beforeLoad({ params: { path }, context: { queryClient }, location }) {
if (!validOrganizationPaths.includes(path)) {
throw notFound()
}
const ensureSessionIso = createIsomorphicFn()
.server(() =>
ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
)
.client(() => ensureSession(queryClient, authClient))
const session = await ensureSessionIso()
if (!session) {
throw redirect({
to: "/auth/$path",
params: { path: "sign-in" },
search: { redirectTo: location.href }
})
}
return { session }
},
component: OrganizationPage
})
function OrganizationPage() {
const { path } = Route.useParams()
return (
<div className="w-full max-w-3xl mx-auto p-4 md:p-6">
<Organization path={path} />
</div>
)
}/organization/settings and /organization/people now render the org management UI. Internal links from <OrganizationSwitcher /> and from <Organizations /> (in the settings tab) wire up automatically.
Users can now create organizations from /settings/organizations. They can use the header switcher and manage members or invitations at /organization/<path>.
Slug-based routes
By default, the switcher stores the selected organization in the session. Enable slug-based routing to select the organization from the URL.
For example, use acme.your-app.com or your-app.com/organization/acme/settings.
This unlocks:
- Shareable, bookmarkable per-org URLs that do not depend on session state
- Server-side prefetching of the correct organization without round-tripping
setActive - Letting members keep multiple organizations open in separate tabs without thrashing the active organization
You wire this up by passing the URL slug into organizationPlugin({ slug }). The UI plugin then:
- Drives
useActiveOrganization()to fetch the org matching that slug (instead of reading the session's active org) - Rewrites every link from
<OrganizationSwitcher />,<Organizations />, and the<Organization />tabs to include/<slug>/ - Swaps the switcher's behavior from
setActivetonavigate: clicking an organization takes the user to its slug-prefixed route
Set slugPrefix to include a marker in the visible route, such as /organization/@acme/settings. Keep slug itself unprefixed.
For example, /organization/@{$slug}/$path captures acme. The plugin adds the prefix to fields, labels, and generated links.
Read the slug from the URL in your providers
Read the slug param wherever you render <AuthProvider> and forward it to organizationPlugin.
import { useNavigate, useParams } from "@tanstack/react-router"
import type { ReactNode } from "react"
import { AuthProvider } from "@/components/auth/auth-provider"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { authClient } from "@/lib/auth-client"
export function Providers({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const { slug } = useParams({ strict: false })
return (
<AuthProvider
authClient={authClient}
navigate={navigate}
plugins={[organizationPlugin({ slug: slug ?? null })]}
>
{children}
</AuthProvider>
)
}On a non-slug page, set slug to null. This value tells the plugin to use the user's personal account.
Do not leave slug as undefined. That value uses the organization from the session and can show the wrong organization.
Add the slug-prefixed organization route
Move /organization/$path → /organization/$slug/$path. Validate both segments and gate on session as before.
import { ensureSession } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-start/server"
import { Organization } from "@/components/auth/organization/organization"
import { auth } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
const validOrganizationPaths = Object.values(
organizationPlugin().viewPaths.organization
)
export const Route = createFileRoute("/organization/$slug/$path")({
async beforeLoad({ params: { path }, context: { queryClient }, location }) {
if (!validOrganizationPaths.includes(path)) {
throw notFound()
}
const ensureSessionIso = createIsomorphicFn()
.server(() =>
ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
)
.client(() => ensureSession(queryClient, authClient))
const session = await ensureSessionIso()
if (!session) {
throw redirect({
to: "/auth/$path",
params: { path: "sign-in" },
search: { redirectTo: location.href }
})
}
return { session }
},
component: OrganizationPage
})
function OrganizationPage() {
const { path } = Route.useParams()
return (
<div className="w-full max-w-3xl mx-auto p-4 md:p-6">
<Organization path={path} />
</div>
)
}After you configure organizationPlugin({ slug }), internal organization links include /<slug>/ automatically.
These links include the switcher, organization rows, and the tab bar in <Organization />.
Customize where the switcher navigates
When slug-based routing is enabled, clicking an organization in <OrganizationSwitcher /> navigates to /organization/<slug>/settings by default, and clicking the personal account navigates to /settings/account.
To use a custom destination such as /<slug>/dashboard, pass the setActive prop. This prop replaces the plugin's default navigation.
The callback receives the selected Organization, or null for the personal account:
import { useNavigate } from "@tanstack/react-router"
import { OrganizationSwitcher } from "@/components/auth/organization/organization-switcher"
export function Header() {
const navigate = useNavigate()
return (
<OrganizationSwitcher
setActive={(organization) => {
navigate({
to: organization ? `/${organization.slug}/dashboard` : "/dashboard"
})
}}
/>
)
}Without slug-based routing, the default behavior is authClient.organization.setActive. Pass setActive to intercept the selection and apply custom behavior.
Hide organization slugs
Set hideSlug: true to hide slugs in creation dialogs, profile forms, organization views, and switchers:
organizationPlugin({ hideSlug: true })Creation still generates a slug from the organization name. Names without Latin letters or digits use a generated identifier instead. The server still validates the slug and its availability. Profile edits preserve the existing slug.
Each component's hideSlug prop overrides the plugin configuration. Without either option, forms and organization views show slugs, while switchers hide them.
Options
organizationPlugin({
// Display slugs as @acme and include the prefix in generated URLs.
slugPrefix: "@",
// Disable the slug-availability check that runs while typing a slug.
checkSlug: false,
// Override path segments (defaults shown).
viewPaths: {
auth: { acceptInvitation: "accept-invitation" },
settings: { organizations: "organizations" },
organization: { settings: "settings", people: "people" }
},
// Disable logo upload, or customize the resize / size.
logo: { enabled: false },
// Replace the default role labels.
roles: { owner: "Owner", admin: "Admin", member: "Member" },
// Add labels for custom server roles without redefining built-ins.
additionalRoles: { billing: "Billing" },
// Limit invitations and members to one role.
allowMultipleRoles: false
})Prop
Type
Localization
Prop
Type
Read these inside custom slot components via useAuthPlugin(organizationPlugin).localization.
React Hooks
Queries
useActiveOrganization(): Full organization for the active session (or the URL slug whenorganizationPlugin({ slug })is set)useListOrganizations(): All organizations the signed-in user belongs touseListOrganizationMembers(): Members of the active organizationuseListOrganizationInvitations(): Pending invitations for the active organizationuseListUserInvitations(): Pending invitations addressed to the signed-in useruseHasPermission({ permissions }): Check the current member's permission against the active organization
Mutations
useCreateOrganization(): Create a new organizationuseUpdateOrganization(): Update name / slug / logo of the active organizationuseDeleteOrganization(): Delete an organizationuseSetActiveOrganization(): Switch the active organization (server-side, persists on session)useLeaveOrganization(): Leave an organizationuseInviteMember(): Invite a member by emailuseRemoveMember(): Remove a memberuseUpdateMemberRole(): Update a member's roleuseCancelInvitation(): Cancel a pending invitationuseAcceptInvitation(): Accept an invitationuseRejectInvitation(): Reject an invitationuseCheckSlug(): Check whether an organization slug is available
Components
<AcceptInvitation />
Direct invitation acceptance view registered by organizationPlugin() at /auth/accept-invitation. It reads invitationId from the query string, preserves the link through sign-in, and lets the recipient accept or reject the pending invitation.
<OrganizationSwitcher />
Dropdown that shows the active organization, lets the user switch between organizations, and exposes a "Create organization" entry. Drop it in your app shell: typically next to <UserButton />.
import { OrganizationSwitcher } from "@/components/auth/organization/organization-switcher"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationSwitcherDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationSwitcher align="start" />
</OrganizationDemoWrapper>
)
}Prop
Type
<Organization />
The full organization management shell mounted at /organization/<path>. Renders settings (profile + danger zone) and people (members + invitations) tabs for the active organization.
import { Organization } from "@/components/auth/organization/organization"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationDemo() {
return (
<OrganizationDemoWrapper>
<Organization view="settings" />
</OrganizationDemoWrapper>
)
}Prop
Type
<OrganizationSettings />
The contents of the settings tab: <OrganizationProfile />, any plugin-contributed organizationCards (for example <OrganizationApiKeys /> from the api-key plugin), then <OrganizationDangerZone />. Drop it into a custom layout if you do not want the tabbed shell.
Organization profile
Danger zone
import { OrganizationSettings } from "@/components/auth/organization/organization-settings"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationSettingsDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationSettings organizationId="org_acme" organizationSlug="acme" />
</OrganizationDemoWrapper>
)
}Prop
Type
<OrganizationProfile />
Editable profile card for the active organization: logo, display name, and slug. Submits via useUpdateOrganization.
Organization profile
import { OrganizationProfile } from "@/components/auth/organization/organization-profile"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationProfileDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationProfile />
</OrganizationDemoWrapper>
)
}Prop
Type
<OrganizationDangerZone />
Danger-zone card with <LeaveOrganization /> and <DeleteOrganization /> rows.
Danger zone
import { OrganizationDangerZone } from "@/components/auth/organization/organization-danger-zone"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationDangerZoneDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationDangerZone />
</OrganizationDemoWrapper>
)
}Prop
Type
<OrganizationPeople />
The contents of the people tab: <OrganizationMembers /> on top, <OrganizationInvitations /> below.
Members
Invitations
import { OrganizationPeople } from "@/components/auth/organization/organization-people"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationPeopleDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationPeople />
</OrganizationDemoWrapper>
)
}Prop
Type
<OrganizationMembers />
Searchable, sortable, filter-by-role table of the active organization's members with an invite control and per-row role / remove actions.
Members
import { OrganizationMembers } from "@/components/auth/organization/organization-members"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationMembersDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationMembers />
</OrganizationDemoWrapper>
)
}Prop
Type
Paginating members
By default <OrganizationMembers /> asks for the whole list and filters it in
the browser. That is fine for a handful of members and quietly wrong for a
large organization: list-members caps its response at 100 rows (or your
membershipLimit) and says nothing about the rest, so members past that point
do not appear.
Pass pageSize to move paging onto the server:
<OrganizationMembers pageSize={20} />In paged mode the component sends limit and offset, applies the role filter
server-side through filterField/filterValue, sorts by role through
sortBy/sortDirection, and reads the row count from the endpoint's total.
Two controls step aside in paged mode, because list-members cannot honour
them across pages:
- The search box. The endpoint has no search parameter. Name and email live on the joined user row, which it does not query.
- Sorting by member. Same reason: it can only sort columns on the member row itself.
The signed-in user's own role comes from getActiveMemberRole rather than from
scanning the loaded page because that member can be on a different page. This is
exposed as useActiveMemberRole for custom components.
list-invitations and list-sessions take no pagination parameters at all, so
those lists stay client-side until Better Auth adds them. list-api-keys does
support limit and offset. Wiring it up is a separate change.
<OrganizationInvitations />
Pending invitations for the active organization as a table aligned with <OrganizationMembers />. Includes role / status filters, plus per-row resend and cancel actions.
Resending re-invites the same address with resend: true, which pushes the
invitation's expiry out and sends the email again instead of creating a second
invitation. The action needs the invitation: ["create"] permission, the same
one that gates <InviteMemberDialog />.
Invitations
import { OrganizationInvitations } from "@/components/auth/organization/organization-invitations"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationInvitationsDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationInvitations />
</OrganizationDemoWrapper>
)
}Prop
Type
<OrganizationsSettings />
The user-level organizations panel rendered at /settings/organizations. Lists every organization the user belongs to (with a "Create organization" button and empty state) plus invitations addressed to the user.
Organizations
Invitations
import { OrganizationsSettings } from "@/components/auth/organization/organizations-settings"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationsSettingsDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationsSettings />
</OrganizationDemoWrapper>
)
}Prop
Type
<Organizations />
List of organizations the user belongs to with a "Create organization" button and per-row Manage control. Embedded inside <OrganizationsSettings />.
Organizations
import { Organizations } from "@/components/auth/organization/organizations"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationsDemo() {
return (
<OrganizationDemoWrapper>
<Organizations />
</OrganizationDemoWrapper>
)
}Prop
Type
<UserInvitations />
Invitations addressed to the signed-in user across every organization, with Accept / Reject actions. Embedded inside <OrganizationsSettings />.
Invitations
import { UserInvitations } from "@/components/auth/organization/user-invitations"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function UserInvitationsDemo() {
return (
<OrganizationDemoWrapper>
<UserInvitations />
</OrganizationDemoWrapper>
)
}Prop
Type
<CreateOrganizationDialog />
Modal dialog with the new-organization form. Owned by <OrganizationSwitcher /> and <Organizations />. Mount it directly when you want to open the create flow from your own surface.
Prop
Type
Multiple roles per member
Better Auth accepts role as a string or an array. It stores multiple roles as
one comma-joined value ("admin,member"). The role pickers allow multiple
roles by default. Set allowMultipleRoles: false to limit invitations and
members to one role. The pickers always keep at least one role selected.
Anywhere you read a raw role, split it first. @better-auth-ui/core/plugins/organization
exports helpers for this:
import {
hasMemberRole,
memberRoleLabels,
parseMemberRoles
} from "@better-auth-ui/core/plugins/organization"
parseMemberRoles("admin,member") // ["admin", "member"]
hasMemberRole("owner,admin", "owner") // true
memberRoleLabels("admin,auditor", roles) // ["Admin", "auditor"]hasMemberRole matters more than it looks: member.role === "owner" silently
misses an owner who also carries a second role, which is what the members list
used to do when deciding whether to show owner-only actions.
<InviteMemberDialog />
Modal dialog for inviting a new member by email. Owned by <OrganizationMembers /> and <OrganizationInvitations />. Mount it directly to drive the invite flow from a custom action.
Prop
Type
<DeleteOrganizationDialog />
Confirmation dialog for deleting an organization (owner permission, server-side).
Prop
Type
Dynamic organization roles
Enable dynamic access control in Better Auth before you enable the roles tab. The server, client, and UI must use the same resource and action keys.
import { createAccessControl } from "better-auth/plugins/access"
export const organizationStatements = {
project: ["create", "read", "update", "delete"]
} as const
export const organizationAccess = createAccessControl(organizationStatements)import { organization } from "better-auth/plugins"
import { organizationAccess } from "./organization-access"
organization({
ac: organizationAccess,
dynamicAccessControl: { enabled: true }
})import { organizationClient } from "better-auth/client/plugins"
import { organizationAccess } from "./organization-access"
organizationClient({
ac: organizationAccess,
dynamicAccessControl: { enabled: true }
})Configure the labels that the permission matrix shows:
organizationPlugin({
dynamicAccessControl: {
enabled: true,
permissions: {
project: {
label: "Projects",
actions: {
create: "Create projects",
read: "View projects",
update: "Edit projects",
delete: "Delete projects"
}
}
}
}
})This adds the roles organization tab. The tab supports role creation, editing, and deletion. Member and invitation controls include both configured roles and roles returned by Better Auth. A role cannot be deleted while a member holds it.
The React hooks also expose the underlying endpoints. Always pass the organization ID:
const roles = useListRoles(authClient, {
query: { organizationId }
})
const role = useRole(authClient, {
query: { organizationId, roleId }
})
const createRole = useCreateRole(authClient, organizationId)
const updateRole = useUpdateRole(authClient, organizationId)
const deleteRole = useDeleteRole(authClient, organizationId)Teams and policy controls
Enable teams on the Better Auth server, client, and UI plugin:
organization({ teams: { enabled: true } })
organizationClient({ teams: { enabled: true } })
organizationPlugin({
teams: {
maximumTeams: 5,
maximumMembersPerTeam: 20,
allowRemovingAllTeams: false
}
})The organization shell adds a Teams tab. Users can create, rename, and delete teams. They can also add and remove team members. Invitations can assign a team.
The UI disables team creation and member assignment when these limits are reached. It also protects the final team unless allowRemovingAllTeams is true. Better Auth also accepts callback limits on the server. The browser cannot run those callbacks, so pass their resolved values into the UI configuration when the limits vary by organization.
The useListTeams, useListTeamMembers, useCreateTeam, useUpdateTeam, useRemoveTeam, useAddTeamMember, and useRemoveTeamMember hooks cover the team lifecycle.
User team selection
TeamSwitcher lists only the teams that the current user can access in the specified organization. The application owns the selected team. This keeps team routing independent from Better Auth active organizations.
import { useState } from "react"
import { TeamSwitcher } from "@/components/auth/organization/team-switcher"
export function ProjectTeamFilter({ organizationId }: { organizationId: string }) {
const [teamId, setTeamId] = useState<string | null>(null)
return (
<TeamSwitcher
organizationId={organizationId}
teamId={teamId}
onTeamChange={(team) => setTeamId(team?.id ?? null)}
/>
)
}Set syncSession when an application still reads session.activeTeamId. This opt-in mode calls Better Auth setActiveTeam after the callback.
The useListUserTeams and useSetActiveTeam hooks are also available for custom controls. Always pass organizationId to both APIs.
Model-specific fields
Configure fields by the Better Auth model that stores them. The names and types must match the schema fields in the server organization plugin.
organizationPlugin({
modelFields: {
organization: [
{ name: "billingCode", type: "string", label: "Billing code" }
],
member: [
{ name: "title", type: "string", label: "Job title" }
],
invitation: [
{ name: "department", type: "string", label: "Department" }
],
team: [
{ name: "description", type: "string", inputType: "textarea", label: "Description" }
],
role: [
{ name: "color", type: "string", label: "Color" }
]
}
})Organization fields appear in creation and profile forms. Invitation, team, and role fields appear in their create and edit workflows. Member fields appear as read-only details because Better Auth does not provide browser client endpoints for creating or editing a member model directly.
Use organizationLimit, membershipLimit, and invitationLimit to disable actions when a configured limit is reached. Use allowOrganizationCreation: false to hide creation controls.
Plugins can add an organization settings tab through organizationTabs. Each tab receives organizationId and organizationSlug for the active organization.
Last updated on