BETTER-AUTH. UI
Plugins

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 organizations tab to <Settings /> listing every organization the user belongs to plus pending invitations to them
  • An <Organization /> shell mounted at /organization/<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 (e.g. api-key) can attach org-scoped cards
  • Hooks and mutations for every organization endpoint (useActiveOrganization, useListOrganizations, useInviteMember, etc.)

Setup

Install the server plugin

Add the organization plugin to your Better Auth server config:

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:

lib/auth-client.ts
import { createAuthClient } from "better-auth/react"
import { organizationClient } from "better-auth/client/plugins"

export const authClient = createAuthClient({
  plugins: [organizationClient()] 
})

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:

components/providers.tsx
import { AuthProvider } from "@better-auth-ui/heroui"
import { organizationPlugin } from "@better-auth-ui/heroui/plugins"

import { authClient } from "@/lib/auth-client"

<AuthProvider
  authClient={authClient}
  plugins={[
    organizationPlugin() 
  ]}
>
  {children}
</AuthProvider>

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:

components/header.tsx
import { UserButton } from "@better-auth-ui/heroui"
import { OrganizationSwitcher } from "@better-auth-ui/heroui/plugins"

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:

routes/settings/$path.tsx
import { viewPaths } from "@better-auth-ui/core"
import { Settings } from "@better-auth-ui/heroui"
import { organizationPlugin } from "@better-auth-ui/heroui/plugins"
import { ensureSession as ensureSessionClient } from "@better-auth-ui/react"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/react/server"
import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-start/server"

import { auth } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"

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 ensureSession = createIsomorphicFn()
      .server(() =>
        ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
      )
      .client(() => ensureSessionClient(queryClient, authClient))

    const session = await ensureSession()

    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>
  )
}
app/settings/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { Settings } from "@better-auth-ui/heroui"
import { organizationPlugin } from "@better-auth-ui/heroui/plugins"
import { ensureSession } from "@better-auth-ui/react/server"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"

import { auth } from "@/lib/auth"
import { getQueryClient } from "@/lib/query-client"

const validSettingsPaths = [
  ...Object.values(viewPaths.settings),
  ...Object.values(organizationPlugin().viewPaths.settings) 
]

export default async function SettingsPage({
  params
}: {
  params: Promise<{ path: string }>
}) {
  const { path } = await params

  if (!validSettingsPaths.includes(path)) {
    notFound()
  }

  const queryClient = getQueryClient()

  const session = await ensureSession(queryClient, auth, {
    headers: await headers()
  })

  if (!session) {
    redirect(
      `/auth/sign-in?redirectTo=${encodeURIComponent(`/settings/${path}`)}`
    )
  }

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <div className="w-full max-w-3xl mx-auto p-4 md:p-6">
        <Settings path={path} />
      </div>
    </HydrationBoundary>
  )
}

/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).

routes/organization/$path.tsx
import {
  Organization,
  organizationPlugin
} from "@better-auth-ui/heroui/plugins"
import { ensureSession as ensureSessionClient } from "@better-auth-ui/react"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/react/server"
import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-start/server"

import { auth } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"

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 ensureSession = createIsomorphicFn()
      .server(() =>
        ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
      )
      .client(() => ensureSessionClient(queryClient, authClient))

    const session = await ensureSession()

    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>
  )
}
app/organization/[path]/page.tsx
import {
  Organization,
  organizationPlugin
} from "@better-auth-ui/heroui/plugins"
import { ensureSession } from "@better-auth-ui/react/server"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"

import { auth } from "@/lib/auth"
import { getQueryClient } from "@/lib/query-client"

const validOrganizationPaths = Object.values(
  organizationPlugin().viewPaths.organization
)

export default async function OrganizationPage({
  params
}: {
  params: Promise<{ path: string }>
}) {
  const { path } = await params

  if (!validOrganizationPaths.includes(path)) {
    notFound()
  }

  const queryClient = getQueryClient()

  const session = await ensureSession(queryClient, auth, {
    headers: await headers()
  })

  if (!session) {
    redirect(
      `/auth/sign-in?redirectTo=${encodeURIComponent(`/organization/${path}`)}`
    )
  }

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <div className="w-full max-w-3xl mx-auto p-4 md:p-6">
        <Organization path={path} />
      </div>
    </HydrationBoundary>
  )
}

/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.

That's it — users can now create organizations from /settings/organizations, switch between them via the header switcher, and manage members and invitations at /organization/<path>.

Slug-based routes

By default the active organization is whatever the user last picked in the switcher (persisted on the session). If you'd rather drive the active organization from the URL — for example acme.your-app.com or your-app.com/organization/acme/settings — you can opt into slug-based routing.

This unlocks:

  • Shareable, bookmarkable per-org URLs that don't 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:

  1. Drives useActiveOrganization() to fetch the org matching that slug (instead of reading the session's active org)
  2. Rewrites every link 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

Read the slug from the URL in your providers

Read the slug param wherever you render <AuthProvider> and forward it to organizationPlugin.

components/providers.tsx
import { AuthProvider } from "@better-auth-ui/heroui"
import { organizationPlugin } from "@better-auth-ui/heroui/plugins"
import { useNavigate, useParams } from "@tanstack/react-router"
import type { ReactNode } from "react"

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>
  )
}
components/providers.tsx
"use client"

import { AuthProvider } from "@better-auth-ui/heroui"
import { organizationPlugin } from "@better-auth-ui/heroui/plugins"
import { useParams, useRouter } from "next/navigation"
import type { ReactNode } from "react"

import { authClient } from "@/lib/auth-client"

export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter()
  const params = useParams<{ slug?: string | string[] }>() 
  const slug = typeof params?.slug === "string" ? params.slug : null

  return (
    <AuthProvider
      authClient={authClient}
      navigate={({ to, replace }) =>
        replace ? router.replace(to) : router.push(to)
      }
      plugins={[organizationPlugin({ slug })]} 
    >
      {children}
    </AuthProvider>
  )
}

When the user is on a non-slug page (/settings/organizations, /dashboard, etc.) slug is null, which tells the plugin "no active organization — this is the user's personal account." Don't leave it as undefined here: that falls back to whatever the session has cached as active, which can leak the wrong org into non-slug pages.

Add the slug-prefixed organization route

Move /organization/$path/organization/$slug/$path. Validate both segments and gate on session as before.

routes/organization/$slug/$path.tsx
import {
  Organization,
  organizationPlugin
} from "@better-auth-ui/heroui/plugins"
import { ensureSession as ensureSessionClient } from "@better-auth-ui/react"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/react/server"
import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-start/server"

import { auth } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"

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 ensureSession = createIsomorphicFn()
      .server(() =>
        ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
      )
      .client(() => ensureSessionClient(queryClient, authClient))

    const session = await ensureSession()

    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>
  )
}
app/organization/[slug]/[path]/page.tsx
import {
  Organization,
  organizationPlugin
} from "@better-auth-ui/heroui/plugins"
import { ensureSession } from "@better-auth-ui/react/server"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"

import { auth } from "@/lib/auth"
import { getQueryClient } from "@/lib/query-client"

const validOrganizationPaths = Object.values(
  organizationPlugin().viewPaths.organization
)

export default async function OrganizationPage({
  params
}: {
  params: Promise<{ slug: string; path: string }>
}) {
  const { slug, path } = await params

  if (!validOrganizationPaths.includes(path)) {
    notFound()
  }

  const queryClient = getQueryClient()

  const session = await ensureSession(queryClient, auth, {
    headers: await headers()
  })

  if (!session) {
    redirect(
      `/auth/sign-in?redirectTo=${encodeURIComponent(`/organization/${slug}/${path}`)}`
    )
  }

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <div className="w-full max-w-3xl mx-auto p-4 md:p-6">
        <Organization path={path} />
      </div>
    </HydrationBoundary>
  )
}

Internal links — the switcher, organization rows in /settings/organizations, and the tab bar inside <Organization /> — automatically include /<slug>/ once organizationPlugin({ slug }) 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, and clicking the personal account navigates to /settings/account.

If you'd rather land on, say, /<slug>/dashboard (or any other per-org surface), pass the setActive prop to take over navigation entirely. The plugin's default navigation is skipped — you receive the picked Organization (or null for personal) and own what happens next:

components/header.tsx
import { OrganizationSwitcher } from "@better-auth-ui/heroui/plugins"
import { useNavigate } from "@tanstack/react-router"

export function Header() {
  const navigate = useNavigate()

  return (
    <OrganizationSwitcher
      setActive={(organization) => {
        navigate({
          to: organization ? `/${organization.slug}/dashboard` : "/dashboard"
        })
      }}
    />
  )
}
components/header.tsx
"use client"

import { OrganizationSwitcher } from "@better-auth-ui/heroui/plugins"
import { useRouter } from "next/navigation"

export function Header() {
  const router = useRouter()

  return (
    <OrganizationSwitcher
      setActive={(organization) => {
        router.push(
          organization ? `/${organization.slug}/dashboard` : "/dashboard"
        )
      }}
    />
  )
}

setActive also works without slug-based routing — in that mode the default behavior is authClient.organization.setActive, and passing setActive lets you intercept the picker (for example, to also persist a per-org preference somewhere else).

Options

organizationPlugin({
  // Disable the slug-availability check that runs while typing a slug.
  checkSlug: false,
  // Override path segments (defaults shown).
  viewPaths: {
    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" }
})

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 when organizationPlugin({ slug }) is set)
  • useListOrganizations() — All organizations the signed-in user belongs to
  • useListOrganizationMembers() — Members of the active organization
  • useListOrganizationInvitations() — Pending invitations for the active organization
  • useListUserInvitations() — Pending invitations addressed to the signed-in user
  • useHasPermission({ permissions }) — 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 (server-side, persists on session)
  • 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

<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 { AuthProvider } from "@better-auth-ui/heroui"
import {
  OrganizationSwitcher,
  organizationPlugin
} from "@better-auth-ui/heroui/plugins"

import { authClient } from "@/lib/auth-client"

export function OrganizationSwitcherDemo() {
  return (
    <AuthProvider
      authClient={authClient}
      navigate={() => {}}
      plugins={[organizationPlugin()]}
    >
      <OrganizationSwitcher placement="bottom" />
    </AuthProvider>
  )
}

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.

Organization profile

Danger zone

import { Organization } from "@better-auth-ui/heroui/plugins"

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 (e.g. <OrganizationApiKeys /> from the api-key plugin), then <OrganizationDangerZone />. Drop it into a custom layout if you don't want the tabbed shell.

Organization profile

Danger zone

import { OrganizationSettings } from "@better-auth-ui/heroui/plugins"

import { OrganizationDemoWrapper } from "./organization-demo-wrapper"

export function OrganizationSettingsDemo() {
  return (
    <OrganizationDemoWrapper>
      <OrganizationSettings />
    </OrganizationDemoWrapper>
  )
}

Prop

Type

<OrganizationProfile />

Editable profile card for the active organization: logo, display name, and slug. Submits via useUpdateOrganization.

Organization profile

import { OrganizationProfile } from "@better-auth-ui/heroui/plugins"

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 "@better-auth-ui/heroui/plugins"

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

MemberRoleActions

Invitations

EmailInvited atRoleStatusActions
import { OrganizationPeople } from "@better-auth-ui/heroui/plugins"

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

MemberRoleActions
import { OrganizationMembers } from "@better-auth-ui/heroui/plugins"

import { OrganizationDemoWrapper } from "./organization-demo-wrapper"

export function OrganizationMembersDemo() {
  return (
    <OrganizationDemoWrapper>
      <OrganizationMembers />
    </OrganizationDemoWrapper>
  )
}

Prop

Type

<OrganizationInvitations />

Pending invitations for the active organization as a table aligned with <OrganizationMembers />. Includes role / status filters and a per-row cancel action.

Invitations

EmailInvited atRoleStatusActions
import { OrganizationInvitations } from "@better-auth-ui/heroui/plugins"

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 "@better-auth-ui/heroui/plugins"

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 "@better-auth-ui/heroui/plugins"

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 "@better-auth-ui/heroui/plugins"

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

<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

Last updated on

On this page