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/$pathwithsettingsandpeopletabs - 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 - 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-ui/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/organization.tsx— the/organization/$slug/$pathshellsrc/components/auth/organization/organization-switcher.tsx— header dropdown for switching organizationssrc/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() })
]}
>
{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.
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 as ensureSessionClient } from "@better-auth-ui/solid"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/solid/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 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 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. <Organization /> is the shell — it shows the settings (profile + danger zone) and people (members + invitations) tabs for the organization identified by the URL slug.
import { ensureSession as ensureSessionClient } from "@better-auth-ui/solid"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/solid/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 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 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.
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/$slug/$path.
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 don't 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
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() })]}
>
{props.children}
</AuthProvider>
</Show>
)
}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 to /organization/$slug/$path. Validate the path segment and gate on the signed-in session as before.
import { ensureSession as ensureSessionClient } from "@better-auth-ui/solid"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/solid/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 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 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>
)
}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 auth.basePaths.settings.
If you'd rather land on a custom route, 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 { 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"
})
}}
/>
)
}setActive also works outside slug-aware routes — in that mode the default behavior is authClient.organization.setActive, and passing setActive lets you intercept the picker.
Options
organizationPlugin({
// Pass a string slug for org routes, null for non-org routes, or omit it to use session fallback.
slug: "acme",
// 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.
Solid Hooks
The copied Zaidan components read the configured client from <AuthProvider> with useAuth(). 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 invitationuseCheckOrganizationSlug()— Check whether an organization slug is available
Components
These previews use seeded Storybook fixtures and do not call live Better Auth endpoints.
<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. Renders settings (profile + danger zone) and people (members + invitations) tabs for the organization identified by the 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 (e.g. <OrganizationApiKeys /> from the api-key plugin), then <OrganizationDangerZone />. Drop it into a custom layout if you don't want the tabbed shell.
import { OrganizationSettings } from "@/components/auth/organization/organization-settings"
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.
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
<OrganizationInvitations />
Pending invitations for the active organization as a table aligned with <OrganizationMembers />. Includes role / status filters and a per-row cancel action.
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
<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
Last updated on