BETTER-AUTH. UI
Plugins

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 organizations tab to <Settings /> listing every organization the user belongs to plus pending invitations to them
  • An <Organization /> shell mounted at /organization/@<slug>/<path> with settings and people tabs
  • An <OrganizationSwitcher /> dropdown to switch the active organization, manage it, or create a new one
  • An organizationCards plugin 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, and useUpdateMemberRole

Setup

Install the server plugin

Add the organization plugin to your Better Auth server config:

src/lib/auth.ts
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:

src/lib/auth-client.ts
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.json

This drops the following into your codebase:

  • src/lib/auth/organization-plugin.tsx: organizationPlugin() factory
  • src/components/auth/organization/accept-invitation.tsx: direct invitation acceptance view
  • src/components/auth/organization/organization.tsx: the prefixed organization route shell
  • src/components/auth/organization/organization-switcher.tsx: header dropdown for switching organizations
  • src/components/auth/organization/organization-roles.tsx: dynamic role and permission editor
  • src/components/auth/organization/team-switcher.tsx: organization-scoped team selection
  • src/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 card
  • src/components/auth/organization/organization-members.tsx: members table with search/filter/sort
  • src/components/auth/organization/organization-member-row.tsx: member row with role, remove, and leave actions
  • src/components/auth/organization/organization-invitations.tsx: invitations table with search/filter/sort
  • src/components/auth/organization/organization-invitation-row.tsx: invitation row with cancel action
  • src/components/auth/organization/organizations.tsx: list of organizations the user belongs to
  • src/components/auth/organization/organization-row.tsx: single organization row in the list
  • src/components/auth/organization/organizations-settings.tsx: /settings/organizations panel
  • src/components/auth/organization/user-invitations.tsx: invitations addressed to the user
  • src/components/auth/organization/user-invitation-row.tsx: invitation row with accept/reject actions
  • src/components/auth/organization/create-organization-dialog.tsx: new-organization dialog
  • src/components/auth/organization/invite-member-dialog.tsx: invite-by-email dialog
  • src/components/auth/organization/delete-organization-dialog.tsx: delete confirmation dialog
  • src/components/auth/organization/delete-organization.tsx: delete danger-zone row
  • src/components/auth/organization/leave-organization.tsx: leave danger-zone row
  • src/components/auth/organization/change-organization-logo.tsx: logo upload control
  • src/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.

src/components/providers.tsx
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} />:

src/routes/auth/$path.tsx
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.

src/components/header.tsx
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.

src/routes/settings/$path.tsx
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.

src/routes/organization/@{$slug}/$path.tsx
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:

  1. Drives useActiveOrganization() to fetch the org matching that slug
  2. Rewrites links from <OrganizationSwitcher />, <Organizations />, and the <Organization /> tabs to include /$slug/
  3. Swaps the switcher's behavior from setActive to navigate: 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.

src/components/providers.tsx
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.

src/routes/organization/@{$slug}/$path.tsx
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:

src/components/header.tsx
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 when organizationPlugin({ slug }) is set, or the active session organization when slug is omitted
  • useListOrganizations(): All organizations the signed-in user belongs to
  • useListOrganizationMembers(): Members of the active organization
  • useListOrganizationInvitations(): Invitations for the active organization
  • useListUserInvitations(): Pending invitations addressed to the signed-in user
  • useHasPermission(): Check the current member's permission against the active organization

Mutations

  • useCreateOrganization(): Create a new organization
  • useUpdateOrganization(): Update name / slug / logo of the active organization
  • useDeleteOrganization(): Delete an organization
  • useSetActiveOrganization(): Switch the active organization outside slug-aware routing
  • useLeaveOrganization(): Leave an organization
  • useInviteMember(): Invite a member by email
  • useRemoveMember(): Remove a member
  • useUpdateMemberRole(): Update a member's role
  • useCancelInvitation(): Cancel a pending invitation
  • useAcceptInvitation(): Accept an invitation
  • useRejectInvitation(): Reject an invitation
  • useCheckSlug(): 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

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.

src/lib/organization-access.ts
import { createAccessControl } from "better-auth/plugins/access"

export const organizationStatements = {
  project: ["create", "read", "update", "delete"]
} as const

export const organizationAccess = createAccessControl(organizationStatements)
src/lib/auth.ts
import { organization } from "better-auth/plugins"
import { organizationAccess } from "./organization-access"

organization({
  ac: organizationAccess,
  dynamicAccessControl: { enabled: true }
})
src/lib/auth-client.ts
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:

src/components/providers.tsx
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

On this page