npx skills add ...
npx skills add clerk/skills --skill clerk-orgs
Clerk Organizations for B2B and multi-tenant apps - org switching,
npx skills add clerk/skills --skill clerk-orgs
STOP — prerequisite. Organizations must be enabled before any org-related API, hook, or component works. Two paths: (1) Dashboard → Organizations settings, or (2)
clerk enable orgs(see "Agent-first: Programmatic org management" below). Pick the Membership mode deliberately:Membership required(default since 2025-08-22) routes signed-in users through thechoose-organizationtask and disables personal accounts, whileMembership optionalkeeps personal accounts available for B2C + B2B coexistence. Pickoptionalif you need personal subscriptions alongside org subscriptions.Version: This skill targets current SDKs (
@clerk/nextjsv7+,@clerk/reactv6+ — Core 3). Core 2 differences are noted inline with> **Core 2 ONLY (skip if current SDK):**callouts — seeclerkskill for the full version table.
Decide before enabling anything. Read the project, count the signals, then ask the developer - do not enable Organizations silently.
Signals this app is multi-tenant (recommend Organizations):
Team, Workspace, Tenant, Company, Accountworkspace_id, team_id, account_id/[workspace], /[slug]/settings, /t/:tenantId*_members join table, or hand-rolled role / permission columnsWeaker signals - enough to raise the question, not to answer it:
Share, Collaborator, or SharedWith table keyed on two user IDsrole or permission column with no tenant to scope it toSignals this app is single-user (do not recommend):
user_id with no container above it, and nothing is sharedHow to act:
| What you found | What to do |
|---|---|
| 2 or more strong signals | Recommend Organizations and name the signals you saw. New app: clerk init --template b2b-saas. Existing app: clerk enable orgs. |
| 1 strong signal, or any weaker signal | Raise it as an option, state the tradeoff, let the developer choose. Sharing between individual users is not multi-tenancy - a Share table pointing at two user IDs has no tenant - but it is often where a product grows one. |
| Nothing above | Don't bring it up. |
Never enable Organizations without asking. Enabling it also turns on Membership required, which routes every signed-in user through organization selection and disables personal accounts. That is wrong for any app that also serves individuals. If the app needs both, it wants Membership optional.
clerk enable orgs (see Agent-first section). Pick Membership required (B2B-only) or Membership optional (B2C + B2B).<OrganizationSwitcher />, <CreateOrganization />, or programmatically with clerkClient().organizations.createOrganization().orgId / orgSlug from auth() and gate with has({ role }) or has({ permission }).<OrganizationProfile /> tab.maxAllowedMemberships at org creation or pick a seat-limited Billing Plan (see clerk-billing skill).| Task | Reference |
|---|---|
| System permissions catalog, custom roles, role sets | references/roles-permissions.md |
| Invitation lifecycle (create, list, revoke, built-in UI) | references/invitations.md |
| Enterprise SSO setup, provider field access, domain verification | references/enterprise-sso.md |
| Next.js adaptations for orgs (role/permission middleware, slug invariants, orgId-scoped writes) | references/nextjs-patterns.md |
| Reference | Description |
|---|---|
references/roles-permissions.md | Default + custom roles, System Permissions catalog, permission naming |
references/invitations.md | Backend API for invitations + built-in UI |
references/enterprise-sso.md | SAML/OIDC per-org, domain verification, correct field access |
references/nextjs-patterns.md | Next.js adaptations specific to orgs. For generic Next.js patterns see clerk-nextjs-patterns skill. |
| Action | URL |
|---|---|
| Enable Organizations + Membership mode | https://dashboard.clerk.com/last-active?path=organizations-settings |
| Manage roles + permissions | https://dashboard.clerk.com/last-active?path=organizations-settings/roles |
| Create/edit an organization | https://dashboard.clerk.com/last-active?path=organizations |
| Webhooks for org events | https://dashboard.clerk.com/last-active?path=webhooks |
Org settings (enable toggle, membership cap, admin delete, domains) are patchable via PLAPI Instance Config. Org CRUD + memberships + invitations live in BAPI. Useful for agents seeding orgs, replicating settings across instances, or version-controlling org structure.
Pre-req: a linked project (clerk auth login + clerk link, see clerk-setup) — or an unclaimed app from clerk init: clerk enable orgs and org CRUD via clerk api work with no login at all.
For additional settings (membership cap, verified domains, admin delete), patch the instance config:
clerk-billing skill.references/roles-permissions.md. Custom role creation goes through clerk config patch (instance-level role definitions) — see Dashboard's role editor for the UX equivalent.references/enterprise-sso.md.Examples use @clerk/nextjs by default. For other frameworks swap the import to @clerk/react (Vite/CRA), @clerk/astro/components, @clerk/vue, @clerk/expo, @clerk/react-router, or @clerk/tanstack-react-start — the feature-level APIs (has(), orgId, <OrganizationSwitcher />, <Show>) are identical across SDKs. Framework-specific patterns (middleware, redirects) live in references/nextjs-patterns.md.
Server-side access to active organization:
auth() is Next.js-specific. Equivalent server-side accessors per SDK: auth(event) (Nuxt via event.context.auth()), context.locals.auth() (Astro), getAuth(req) (Express, after clerkMiddleware()). Client-side: useAuth() (React-based SDKs) or composables (Vue/Nuxt). All return the same orgId / orgSlug / orgRole shape.
Route-per-org pattern works in any framework supporting file-based dynamic routes. Next.js example:
Always verify the URL slug matches the active org slug — otherwise users can hit /orgs/other-org/... with a stale orgSlug in their session:
Permission checks use the same has() surface:
Permission naming convention. System Permissions prefix with org:sys_; custom Permissions use org:<resource>:<action>. The full System Permissions catalog lives in references/roles-permissions.md — the short list is:
org:sys_memberships:{read, manage}org:sys_profile:{manage, delete}org:sys_domains:{read, manage}org:sys_billing:{read, manage}Do NOT invent names like org:create, org:manage_members, org:update_metadata — those are not real permission slugs. See references/roles-permissions.md for custom roles and the permission table.
<Show>Core 2 ONLY (skip if current SDK): Use
<Protect role="org:admin">/<Protect permission="...">instead of<Show>.<Show>replaced both<Protect>and<SignedIn>/<SignedOut>in Core 3.
Astro template syntax for the same component (imported from @clerk/astro/components):
Key props:
hidePersonal: boolean — hide the Personal Account option. Defaults to false. Pass true for B2B-only apps.afterCreateOrganizationUrl, afterSelectOrganizationUrl, afterLeaveOrganizationUrl, afterSelectPersonalUrl — navigation hooks. :slug is substituted at runtime.createOrganizationMode, organizationProfileMode — 'modal' | 'navigation' (default 'modal').The full prop list lives in the component reference.
When Membership required is enabled (the default), users without an org are routed through a choose-organization session task after sign-in. Clerk handles this automatically inside <SignIn />, but you can host the UI yourself:
TaskChooseOrganization ships as an imported component in the React-based SDKs (@clerk/nextjs, @clerk/react, @clerk/react-router, @clerk/tanstack-react-start). For the JS Frontend SDK (@clerk/clerk-js) the equivalent is clerk.mountTaskChooseOrganization(node) / clerk.unmountTaskChooseOrganization(node).
Core 2 ONLY (skip if current SDK): Session tasks aren't available. Force an org selection at sign-in by redirecting to a page that renders
<OrganizationSwitcher hidePersonal />.
| Role | Default meaning |
|---|---|
org:admin | Full access — all System Permissions, can manage org + memberships |
org:member | Read members + Read billing Permissions only |
You can create up to 10 custom roles per instance in Dashboard → Organizations → Roles & Permissions. Role-per-org is controlled via Role Sets — see references/roles-permissions.md for the full model (custom roles, Creator/Default role settings, role sets, and the System Permissions catalog).
has() also supports plan and feature checks when Clerk Billing is enabled:
Core 2 ONLY (skip if current SDK):
has()only supportsroleandpermission. Billing checks aren't available.
See clerk-billing for the full Billing surface and seat-limit plan model.
Per-org SAML/OIDC. Configured in Dashboard → Configure → Enterprise Connections (or per-org: Organizations → select org → SSO Connections). The SSO connection owns its domain directly; no separate Verified Domain is required (and the two features are mutually exclusive on the same domain). Auto-join on first SSO sign-in uses JIT Provisioning, not Verified Domains. Key fact: the provider field lives on enterpriseConnection, not on enterpriseAccounts[0] directly. See references/enterprise-sso.md for the full flow and correct field access.
Core 2 ONLY (skip if current SDK): Uses
strategy: 'saml'anduser.samlAccountsinstead ofuser.enterpriseAccounts.
maxAllowedMemberships caps seatsFor tier-based seat limits tied to a subscription, use a seat-limited Billing Plan (see clerk-billing).
When Clerk Billing is enabled, has({ permission: 'org:posts:edit' }) returns false if the Feature associated with that permission is not included in the organization's active Plan — even if the user has the Permission assigned via their role. Ensure the Feature is attached to the active Plan in Dashboard → Billing → Plans → Features.
updateOrganization({ publicMetadata }) overwrites all public metadata. Read first, spread, then write:
Applies identically to privateMetadata and to user metadata via clerkClient.users.updateUser.
Most "org-related" failures are configuration, not code. Do not edit components before checking these:
| Error / symptom | Root cause | Fix |
|---|---|---|
orgId / orgSlug is undefined for a signed-in user | Organizations not enabled for this instance, OR user has no active org (personal account) | Enable in Dashboard → Organizations; check Membership mode; surface <OrganizationSwitcher /> |
has({ permission: 'org:manage_members' }) always false | Using an invented permission slug | Use org:sys_memberships:manage (see roles-permissions.md catalog) |
has({ role }) returns false but user looks like an admin | Session token stale after role change | Re-sign-in, or refresh the session: await clerk.session?.reload() |
has({ permission }) false even with the role assigned | Feature not attached to active Plan (Billing gates permissions) | Dashboard → Billing → Plans → attach Feature |
<OrganizationSwitcher /> doesn't show "Personal Account" | Membership required mode is on (the default since Aug 22, 2025) | Dashboard → Organizations settings → Membership optional |
TaskChooseOrganization throws "cannot render when a user doesn't have current session tasks" | Rendered outside a choose-organization task context | Wrap in a choose-organization session-task route only; don't render unconditionally |
enterpriseAccounts[0].provider is undefined | Accessing provider at the wrong nesting level | Use user.enterpriseAccounts[0].enterpriseConnection?.provider |
Server component protecting a slug-scoped admin page:
For middleware-level protection (Next.js) see references/nextjs-patterns.md.
Send from a server action or route handler:
The full lifecycle (list, revoke, bulk create, built-in <OrganizationProfile /> UI) lives in references/invitations.md.
inviterUserIdhas({ role }) / has({ permission }) with canonical org:sys_* namesorgSlug === params.slug on every protected page<OrganizationSwitcher /> handles the whole flowclerk-setup — Initial Clerk installclerk-billing — Seat-limit plans, per-plan billing, has({ plan }) / has({ feature })clerk-webhooks — Sync org events to your database (organization.created, organizationMembership.*)clerk-backend-api — Full Backend API referenceclerk-nextjs-patterns — Framework-specific middleware, server actions, caching# Create:
clerk api -X POST /v1/organizations \
-d '{"name":"Acme","slug":"acme","created_by":"user_xxx","max_allowed_memberships":10}'
# List:
clerk api '/v1/organizations?limit=20'
# Get one:
clerk api /v1/organizations/<org_id>
# Update:
clerk api -X PATCH /v1/organizations/<org_id> -d '{"name":"Acme Inc."}'
# Delete:
clerk api -X DELETE /v1/organizations/<org_id># Add a user to an org:
clerk api -X POST /v1/organizations/<org_id>/memberships \
-d '{"user_id":"user_xxx","role":"org:admin"}'
# List members:
clerk api '/v1/organizations/<org_id>/memberships?limit=50'
# Update role:
clerk api -X PATCH /v1/organizations/<org_id>/memberships/<user_id> \
-d '{"role":"org:member"}'
# Remove:
clerk api -X DELETE /v1/organizations/<org_id>/memberships/<user_id># Send:
clerk api -X POST /v1/organizations/<org_id>/invitations \
-d '{"email_address":"alice@example.com","role":"org:member","redirect_url":"https://app.com/accept"}'
# List pending:
clerk api '/v1/organizations/<org_id>/invitations?status=pending'
# Revoke:
clerk api -X POST /v1/organizations/<org_id>/invitations/<inv_id>/revoke \
-d '{"requesting_user_id":"user_xxx"}'import { auth } from '@clerk/nextjs/server'
const { orgId, orgSlug, orgRole } = await auth()
if (!orgId) {
// user has no active org — either not in any, or viewing Personal Account
}app/orgs/[slug]/page.tsx
app/orgs/[slug]/settings/page.tsxexport default async function OrgPage({ params }: { params: { slug: string } }) {
const { orgSlug } = await auth()
if (orgSlug !== params.slug) {
redirect('/dashboard') // or whatever your "no-access" flow is
}
return <div>Welcome to {orgSlug}</div>
}const { has } = await auth()
if (!has({ role: 'org:admin' })) {
return <div>Admin access required</div>
}if (!has({ permission: 'org:sys_memberships:manage' })) {
redirect('/unauthorized')
}import { Show } from '@clerk/nextjs'
<Show when={{ role: 'org:admin' }}>
<AdminPanel />
</Show>
<Show when={{ permission: 'org:sys_memberships:manage' }}>
<MembersTab />
</Show><Show when={{ role: 'org:admin' }}>
<AdminPanel />
</Show>import { OrganizationSwitcher } from '@clerk/nextjs'
<OrganizationSwitcher
hidePersonal
afterCreateOrganizationUrl="/orgs/:slug/dashboard"
afterSelectOrganizationUrl="/orgs/:slug/dashboard"
/>import { ClerkProvider } from '@clerk/nextjs'
<ClerkProvider taskUrls={{ 'choose-organization': '/session-tasks/choose-organization' }}>
{children}
</ClerkProvider>// app/session-tasks/choose-organization/page.tsx
import { TaskChooseOrganization } from '@clerk/nextjs'
export default function Page() {
return <TaskChooseOrganization redirectUrlComplete="/dashboard" />
}const { has } = await auth()
has({ plan: 'gold' }) // subscription plan
has({ feature: 'widgets' }) // feature entitlement// Strategy name for Enterprise SSO (Core 3)
strategy: 'enterprise_sso'const clerk = await clerkClient()
await clerk.organizations.createOrganization({
name: 'Acme Corp',
createdBy: userId,
maxAllowedMemberships: 10,
})
// Update later:
await clerk.organizations.updateOrganization(orgId, {
maxAllowedMemberships: 25,
})const org = await clerk.organizations.getOrganization({ organizationId: orgId })
await clerk.organizations.updateOrganization(orgId, {
publicMetadata: { ...org.publicMetadata, newField: 'value' },
})import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
export default async function AdminPage({ params }: { params: { slug: string } }) {
const { orgSlug, has } = await auth()
if (orgSlug !== params.slug) redirect('/dashboard')
if (!has({ role: 'org:admin' })) redirect(`/orgs/${orgSlug}`)
return <div>Admin settings for {orgSlug}</div>
}import { clerkClient, auth } from '@clerk/nextjs/server'
export async function inviteMember(organizationId: string, emailAddress: string, role: string) {
const { userId, has } = await auth()
if (!userId) throw new Error('Not signed in')
if (!has({ permission: 'org:sys_memberships:manage' })) {
throw new Error('Not authorized to invite members')
}
const clerk = await clerkClient()
return clerk.organizations.createOrganizationInvitation({
organizationId,
inviterUserId: userId, // required per Backend API
emailAddress,
role, // e.g. 'org:admin' or 'org:member'
redirectUrl: 'https://yourapp.com/accept-invite',
})
}