Clerk Organizations for B2B SaaS - create multi-tenant apps with org switching, role-based access, verified domains, and enterprise SSO. Use for team workspaces, RBAC, org-based routing, member management.
npx skills add https://github.com/clerk/stripe-webhooks-example --skill clerk-orgs
> Prerequisite: Enable Organizations in Clerk Dashboard first.
>
> Version: Check package.json for the SDK version — see clerk skill for the version table. Core 2 differences are noted inline with > Core 2 ONLY (skip if current SDK): callouts.
| Task | Link |
|------|------|
| Overview | https://clerk.com/docs/guides/organizations/overview |
| Org slugs in URLs | https://clerk.com/docs/guides/organizations/org-slugs-in-urls |
| Roles & permissions | https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions |
| Check access | https://clerk.com/docs/guides/organizations/control-access/check-access |
| Invitations | https://clerk.com/docs/guides/organizations/add-members/invitations |
| OrganizationSwitcher | https://clerk.com/docs/reference/components/organization/organization-switcher |
| Verified domains | https://clerk.com/docs/guides/organizations/verified-domains |
| Enterprise SSO | https://clerk.com/docs/guides/organizations/add-members/sso |
Server-side access to organization:
import { auth } from '@clerk/nextjs/server'
const { orgId, orgSlug } = await auth()
console.log(`Current org: ${orgSlug}`)
Create routes that accept org slug:
app/orgs/[slug]/page.tsx
app/orgs/[slug]/settings/page.tsx
Access the slug:
export default function DashboardPage({ params }: { params: { slug: string } }) {
return <div>Organization: {params.slug}</div>
}
Verify user has access to specific org:
import { auth } from '@clerk/nextjs/server'
export default async function ProtectedPage() {
const { orgId, orgSlug } = await auth()
if (!orgId) {
return <div>Not in an organization</div>
}
return <div>Welcome to {orgSlug}</div>
}
Check if user has specific role:
const { has } = await auth()
if (!has({ role: 'org:admin' })) {
return <div>Admin access required</div>
}
Create custom roles and permissions in Dashboard → Organizations → Roles. Permission format: org:resource:action.
// Server component — check a custom billing permission
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
export default async function BillingPage() {
const { has } = await auth()
if (!has({ permission: 'org:billing:manage' })) {
redirect('/unauthorized')
}
return <BillingDashboard />
}
// Middleware — protect an entire route segment
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isBillingRoute = createRouteMatcher(['/orgs/:slug/billing(.*)'])
export default clerkMiddleware(async (auth, req) => {
if (isBillingRoute(req)) {
await auth.protect({ permission: 'org:billing:manage' })
}
})
// Client component — conditional rendering
import { Show } from '@clerk/nextjs'
<Show when={{ permission: 'org:billing:manage' }}>
<BillingSettings />
</Show>
Permission naming convention: org:resource:action (e.g., org:billing:manage, org:reports:view, org:api_keys:create). Always prefix with org: for organization-scoped permissions.
Let users switch between organizations:
import { OrganizationSwitcher } from '@clerk/nextjs'
export default function Nav() {
return (
<header>
<h1>Dashboard</h1>
<OrganizationSwitcher />
</header>
)
}
All new members get assigned a role:
| Role | Permissions |
|------|-------------|
| org:admin | Full access, manage members, settings |
| org:member | Limited access, read-only |
Custom roles can be created in the dashboard.
| Permission | Role |
|-----------|------|
| org:create | Can create new organizations |
| org:manage_members | Can invite/remove members (default: admin) |
| org:manage_roles | Can change member roles (default: admin) |
| org:update_metadata | Can update org metadata (default: admin) |
Complete example protecting a route:
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()
// Verify user is in the org
if (orgSlug !== params.slug) {
redirect('/dashboard')
}
// Check if admin
if (!has({ role: 'org:admin' })) {
redirect(`/orgs/${orgSlug}`)
}
return <div>Admin settings for {orgSlug}</div>
}
<Show>Use <Show> for role-based conditional rendering in client components:
import { Show } from '@clerk/nextjs'
<Show when={{ role: 'org:admin' }}>
<AdminPanel />
</Show>
<Show when={{ permission: 'org:billing:manage' }}>
<BillingSettings />
</Show>
> Core 2 ONLY (skip if current SDK): Use <Protect role="org:admin"> and <Protect permission="org:billing:manage"> instead of <Show>.
The has() method supports billing plan and feature checks for gating access:
const { has } = await auth()
has({ plan: 'gold' }) // Check subscription plan
has({ feature: 'widgets' }) // Check feature entitlement
> Core 2 ONLY (skip if current SDK): has() only supports role and permission parameters. Billing checks are not available.
When personal accounts are disabled, users must choose an organization after sign-in. This is handled by the choose-organization session task:
import { TaskChooseOrganization } from '@clerk/nextjs'
// Renders when user must select an org
<TaskChooseOrganization redirectUrlComplete="/dashboard" />
> Core 2 ONLY (skip if current SDK): Session tasks are not available. Use <OrganizationSwitcher> for org selection.
Organizations can use Enterprise SSO (SAML/OIDC) for member authentication:
// Strategy name for Enterprise SSO
strategy: 'enterprise_sso'
// Access enterprise accounts on user object
user.enterpriseAccounts
> Core 2 ONLY (skip if current SDK): Uses strategy: 'saml' instead of strategy: 'enterprise_sso', and user.samlAccounts instead of user.enterpriseAccounts.
Enterprise SSO is configured per organization in the Clerk Dashboard under Organizations > SSO Connections. Steps:
// Check if the active user authenticated via enterprise SSO
import { currentUser } from '@clerk/nextjs/server'
const user = await currentUser()
const ssoAccount = user?.enterpriseAccounts?.[0]
if (ssoAccount) {
console.log(`SSO provider: ${ssoAccount.provider}`)
console.log(`SSO domain: ${ssoAccount.emailAddress}`)
}
Key facts:
enterprise_sso (used in signIn.supportedFirstFactors)maxAllowedMembershipsPass maxAllowedMemberships when creating an org to cap the number of seats. Attempts to add members beyond the cap will return an error.
const clerk = await clerkClient()
const org = await clerk.organizations.createOrganization({
name: 'Acme Corp',
createdBy: userId,
maxAllowedMemberships: 10,
})
You can also update the cap after creation:
await clerk.organizations.updateOrganization(orgId, {
maxAllowedMemberships: 25,
})
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. Billing gates permissions at the feature level. Ensure the required Feature is attached to the active Plan in Dashboard → Billing → Plans → Features before debugging role assignments.
updateOrganization({ publicMetadata: { tier: 'enterprise' } }) REPLACES all public metadata, not merges it. Read first, spread, then write.
Wrong:
await clerk.organizations.updateOrganization(orgId, {
publicMetadata: { newField: 'value' },
})
Right:
const org = await clerk.organizations.getOrganization(orgId)
await clerk.organizations.updateOrganization(orgId, {
publicMetadata: { ...org.publicMetadata, newField: 'value' },
})
The same rule applies to privateMetadata and to user metadata via clerkClient.users.updateUser.
| Symptom | Cause | Solution |
|---------|-------|----------|
| orgSlug is undefined | Not calling await auth() | Use const { orgSlug } = await auth() |
| Role check always fails | Not awaiting auth() | Add await before auth() |
| Users can access other orgs | Not checking orgSlug matches URL | Verify orgSlug === params.slug |
| Org not appearing in switcher | Organizations not enabled | Enable in Clerk Dashboard → Organizations |
| Invitations not working | Wrong role configuration | Ensure members have invite role permissions |
import { clerkClient } from '@clerk/nextjs/server'
import { auth } from '@clerk/nextjs/server'
export async function inviteMember(organizationId: string, emailAddress: string, role: string) {
const { has } = await auth()
if (!has({ permission: 'org:sys_memberships:manage' })) {
throw new Error('Not authorized to invite members')
}
const clerk = await clerkClient()
const invitation = await clerk.organizations.createOrganizationInvitation({
organizationId,
emailAddress,
role, // e.g. 'org:member' or 'org:admin'
redirectUrl: 'https://yourapp.com/accept-invite',
})
return invitation
}
const clerk = await clerkClient()
const { data: invitations } = await clerk.organizations.getOrganizationInvitationList({
organizationId,
status: ['pending'], // 'pending' | 'accepted' | 'revoked'
})
await clerk.organizations.revokeOrganizationInvitation({
organizationId,
invitationId,
requestingUserId: userId,
})
<OrganizationSwitcher /> includes a built-in member invitation UI when personal accounts are hidden:
<OrganizationSwitcher
hidePersonal
afterCreateOrganizationUrl="/orgs/:slug/dashboard"
afterSelectOrganizationUrl="/orgs/:slug/dashboard"
/>
The <OrganizationProfile /> component also provides a full members management tab with invitation and role management.
clerk-setup - Initial Clerk installclerk-webhooks - Sync org events to your databaseclerk-backend-api - Manage members programmaticallyTake clerk/stripe-webhooks-example-clerk-orgs from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.