Organization
Add multi-tenant organization management with members, invitations, and roles to your authentication UI.
The organization plugin adds multi-tenant organization management to your authentication UI. Users can create, switch between, and manage organizations with members, invitations, and roles.
It contributes:
- An
organizationstab to<Settings />listing every organization the user belongs to plus pending invitations to them - An
<Organization />shell mounted at/organization/<path>withsettingsandpeopletabs - An
<OrganizationSwitcher />dropdown to switch the active organization, manage it, or create a new one - An
organizationCardsplugin slot rendered inside<OrganizationSettings />so other plugins (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:
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
export const auth = betterAuth({
// ...
plugins: [
organization()
]
})Install the matching client plugin
Add organizationClient() to your auth client so authClient.organization.* methods are available:
import { createAuthClient } from "better-auth/react"
import { organizationClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [organizationClient()]
})Register the UI plugin
Pass organizationPlugin() to <AuthProvider> so the organizations settings tab, the <Organization /> shell, and <OrganizationSwitcher /> can read plugin localization and view paths:
import { AuthProvider } from "@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:
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:
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>
)
}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).
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>
)
}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:
- Drives
useActiveOrganization()to fetch the org matching that slug (instead of reading the session's active org) - Rewrites every link from
<OrganizationSwitcher />,<Organizations />, and the<Organization />tabs to include/<slug>/ - Swaps the switcher's behavior from
setActivetonavigate— clicking an organization takes the user to its slug-prefixed route
Read the slug from the URL in your providers
Read the slug param wherever you render <AuthProvider> and forward it to organizationPlugin.
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>
)
}"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.
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>
)
}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:
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"
})
}}
/>
)
}"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 whenorganizationPlugin({ slug })is set)useListOrganizations()— All organizations the signed-in user belongs touseListOrganizationMembers()— Members of the active organizationuseListOrganizationInvitations()— Pending invitations for the active organizationuseListUserInvitations()— Pending invitations addressed to the signed-in useruseHasPermission({ permissions })— Check the current member's permission against the active organization
Mutations
useCreateOrganization()— Create a new organizationuseUpdateOrganization()— Update name / slug / logo of the active organizationuseDeleteOrganization()— Delete an organizationuseSetActiveOrganization()— Switch the active organization (server-side, persists on session)useLeaveOrganization()— Leave an organizationuseInviteMember()— Invite a member by emailuseRemoveMember()— Remove a memberuseUpdateMemberRole()— Update a member's roleuseCancelInvitation()— Cancel a pending invitationuseAcceptInvitation()— Accept an invitationuseRejectInvitation()— Reject an invitationuseCheckSlug()— Check whether an organization slug is available
Components
<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
| Member | Role | Actions |
|---|---|---|
Invitations
| Invited at | Role | Status | Actions | |
|---|---|---|---|---|
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
| Member | Role | Actions |
|---|---|---|
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
| Invited at | Role | Status | Actions | |
|---|---|---|---|---|
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