Organization
Add multi-tenant organization management with members, invitations, and roles to your Solid/Zaidan auth UI.
The organization plugin adds multi-tenant organization management to your Solid/Zaidan 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/@<slug>/<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 - Solid hooks and mutations for organization endpoints such as
useActiveOrganization,useListOrganizations,useInviteMember, anduseUpdateMemberRole
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/solid"
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 https://better-auth-ui.com/r/solid/organization.jsonThis drops the following into your codebase:
src/lib/auth/organization-plugin.tsx:organizationPlugin()factorysrc/components/auth/organization/accept-invitation.tsx: direct invitation acceptance viewsrc/components/auth/organization/organization.tsx: the prefixed organization route shellsrc/components/auth/organization/organization-switcher.tsx: header dropdown for switching organizationssrc/components/auth/organization/organization-roles.tsx: dynamic role and permission editorsrc/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, remove, and leave 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/delete-organization.tsx: delete danger-zone rowsrc/components/auth/organization/leave-organization.tsx: leave danger-zone rowsrc/components/auth/organization/change-organization-logo.tsx: logo upload controlsrc/components/auth/organization/organization-logo.tsx,slug-field.tsx, plus matching loading and empty states
Register the UI plugin
Pass organizationPlugin() to <AuthProvider> so the organizations settings tab, <Organization /> shell, and <OrganizationSwitcher /> can read plugin localization and view paths.
import type { QueryClient } from "@tanstack/solid-query"
import { useNavigate, useParams } from "@tanstack/solid-router"
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { AuthProvider } from "@/components/auth/auth-provider"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { authClient } from "@/lib/auth-client"
export type ProvidersProps = {
children?: JSX.Element | (() => JSX.Element)
queryClient?: QueryClient
}
export function Providers(props: ProvidersProps) {
const navigate = useNavigate()
const params = useParams({ strict: false })
const organizationSlug = () => {
const slug = params().slug
if (typeof slug === "string" && slug.length > 0) return slug
return null
}
return (
<Show keyed when={organizationSlug() ?? "personal"}>
<AuthProvider
authClient={authClient}
redirectTo="/settings/account"
navigate={navigate}
queryClient={props.queryClient}
plugins={[
organizationPlugin({
slug: organizationSlug(),
slugPrefix: "@"
})
]}
>
{props.children}
</AuthProvider>
</Show>
)
}Show keyed remounts the provider when the URL slug changes, so plugin state and organization-scoped queries cannot keep using a previous slug.
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
Drop <OrganizationSwitcher /> into your app shell: typically in the header next to <UserButton />. It shows the active organization, lets users switch between organizations, and exposes a "Create organization" entry.
import { OrganizationSwitcher } from "@/components/auth/organization/organization-switcher"
import { UserButton } from "@/components/auth/user/user-button"
export function Header() {
return (
<header class="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 { ensureSession } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { viewPaths } from "@better-auth-ui/core"
import { createFileRoute, notFound, redirect } from "@tanstack/solid-router"
import { createIsomorphicFn } from "@tanstack/solid-start"
import { getRequestHeaders } from "@tanstack/solid-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 params = Route.useParams()
return (
<div class="mx-auto w-full max-w-3xl p-4 md:p-6">
<Settings path={params().path} />
</div>
)
}/settings/organizations now renders <OrganizationsSettings />: the list of organizations the user belongs to plus pending invitations addressed to them.
Create the organization page
Mount a dynamic route at /organization/@{$slug}/$path that renders <Organization /> for the matching tab. The literal @ stays in the URL while the router captures the unprefixed slug. <Organization /> shows the settings and people tabs for that organization.
import { ensureSession } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { createFileRoute, notFound, redirect } from "@tanstack/solid-router"
import { createIsomorphicFn } from "@tanstack/solid-start"
import { getRequestHeaders } from "@tanstack/solid-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 params = Route.useParams()
return (
<div class="mx-auto w-full max-w-3xl p-4 md:p-6">
<Organization path={params().path} slug={params().slug} />
</div>
)
}/organization/@acme/settings and /organization/@acme/people now render the organization management UI. Internal links from <OrganizationSwitcher /> and <Organizations /> wire up automatically.
Users can now create organizations from /settings/organizations, switch between them from the header, and manage members and invitations at prefixed organization URLs.
Slug-based routes
The Solid/Zaidan organization UI uses slug-based routes for organization pages. This avoids relying on stale session-active organization state when users open multiple organizations or navigate directly to a saved link.
This unlocks:
- Shareable, bookmarkable per-org URLs that do not depend on session state
- Fetching the organization identified by the URL 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 - Rewrites links 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
The example also passes slugPrefix: "@". Solid fields and organization labels display @acme, generated links use /organization/@acme/..., and the router still passes the raw acme slug to Better Auth.
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/solid-router"
import { Show } from "solid-js"
import { AuthProvider } from "@/components/auth/auth-provider"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { authClient } from "@/lib/auth-client"
export function Providers(props: ProvidersProps) {
const navigate = useNavigate()
const params = useParams({ strict: false })
const organizationSlug = () => {
const slug = params().slug
if (typeof slug === "string" && slug.length > 0) return slug
return null
}
return (
<Show keyed when={organizationSlug() ?? "personal"}>
<AuthProvider
authClient={authClient}
navigate={navigate}
queryClient={props.queryClient}
plugins={[
organizationPlugin({
slug: organizationSlug(),
slugPrefix: "@"
})
]}
>
{props.children}
</AuthProvider>
</Show>
)
}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 to /organization/@{$slug}/$path. Validate the path segment and gate on the signed-in session as before.
import { ensureSession } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { createFileRoute, notFound, redirect } from "@tanstack/solid-router"
import { createIsomorphicFn } from "@tanstack/solid-start"
import { getRequestHeaders } from "@tanstack/solid-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 params = Route.useParams()
return (
<div class="mx-auto w-full max-w-3xl p-4 md:p-6">
<Organization path={params().path} slug={params().slug} />
</div>
)
}The switcher, organization rows, and tabs automatically include /@<slug>/ once organizationPlugin({ slug, slugPrefix: "@" }) is wired in.
Customize where the switcher navigates
When slug-based routing is enabled, clicking an organization in <OrganizationSwitcher /> navigates to /organization/@<slug>/settings by default. Clicking the personal account navigates to auth.basePaths.settings.
To use a custom destination, 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/solid-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"
})
}}
/>
)
}Outside slug-aware routes, the default behavior is authClient.organization.setActive. Pass setActive to intercept the selection.
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({
// Pass a string slug for org routes, null for non-org routes, or omit it to use session fallback.
slug: "acme",
// 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.
Solid Hooks
Copied Zaidan components are wired through <AuthProvider>. If you build custom organization UI with the low-level @better-auth-ui/solid hooks, pass your configured authClient as shown in the linked Solid API docs.
Queries
useActiveOrganization(): Full organization for the URL slug whenorganizationPlugin({ slug })is set, or the active session organization when slug is omitteduseListOrganizations(): All organizations the signed-in user belongs touseListOrganizationMembers(): Members of the active organizationuseListOrganizationInvitations(): Invitations for the active organizationuseListUserInvitations(): Pending invitations addressed to the signed-in useruseHasPermission(): 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 outside slug-aware routinguseLeaveOrganization(): 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
These previews use seeded Storybook fixtures and do not call live Better Auth endpoints.
<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 />
</OrganizationDemoWrapper>
)
}Prop
Type
<Organization />
The full organization management shell mounted at /organization/@<slug>/<path>. It renders settings and people tabs for the organization identified by the raw slug.
import { Organization } from "@/components/auth/organization/organization"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationDemo() {
return (
<OrganizationDemoWrapper>
<Organization path="settings" slug="acme" />
</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.
import { OrganizationSettings } from "@/components/auth/organization/organization-settings"
import { OrganizationDemoWrapper } from "./organization-demo-wrapper"
export function OrganizationSettingsDemo() {
return (
<OrganizationDemoWrapper>
<OrganizationSettings
organizationId="organization-id"
organizationSlug="acme"
/>
</OrganizationDemoWrapper>
)
}Prop
Type
<OrganizationProfile />
Editable profile card for the active organization: logo, display name, and slug. Submits via useUpdateOrganization.
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.
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.
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.
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 />.
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.
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 />.
Prop
Type
<UserInvitations />
Invitations addressed to the signed-in user across every organization, with Accept / Reject actions. Embedded inside <OrganizationsSettings />.
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
Related APIs
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 Solid 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 { createSignal } from "solid-js"
import { TeamSwitcher } from "@/components/auth/organization/team-switcher"
export function ProjectTeamFilter(props: { organizationId: string }) {
const [teamId, setTeamId] = createSignal<string | null>(null)
return (
<TeamSwitcher
organizationId={props.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