Subchapter 6.1
references/b2b-patterns.mdMarkdown5 KBView on GitHub
B2B billing in Clerk attaches subscriptions to organizations, not individual users. Each org gets its own subscription. Plans can carry a seat limit (membership cap) which Clerk enforces on member invites.
Create the plan as an Organization Plan, not a User Plan. Use Dashboard → Billing → Plans (opens in a new tab) (Organization Plans tab) or
clerk config patchwithbilling.plans. Slugs are scoped per type. Ateamplan registered under User Plans will not appear in<PricingTable for="organization" />, and vice versa. Plan type cannot be changed after creation, recreate if misplaced.
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
export default async function TeamDashboard() {
const { orgId, has } = await auth()
if (!orgId) {
redirect('/sign-in')
}
if (!has({ plan: 'org:team' })) {
redirect('/billing')
}
return <TeamFeatures />
}Always check orgId first. If the user has no active org, has({ plan }) evaluates against the user’s personal subscription (which may not exist).
Clerk Billing’s B2B model is seat-limit plans: each organization plan has a fixed price and an optional membership cap; Clerk enforces the cap at invite/join time. To charge larger orgs more, create tiered plans (e.g. starter capped at 5, team at 10, enterprise unlimited) with increasing fixed prices.
Key invariants:
active SubscriptionItem per payer per Plan. Do not derive seat count from items.length.clerk config patch); it cannot be changed later.No custom seat-counting code is needed. Read the active plan with has({ plan: 'org:team' }) and let Clerk enforce membership limits.
Use <OrganizationProfile /> for the org account billing UI. It renders the active org plan, members, invitations, and the upgrade / cancellation flow scoped to the active organization, with admin-only access to billing actions enforced by Clerk:
import { OrganizationProfile } from '@clerk/nextjs'
export default function OrgAccountPage() {
return <OrganizationProfile />
}Organization Plans configured in Dashboard → Billing → Plans automatically appear inside <OrganizationProfile /> (in the Plans section). Only org admins see the billing controls. Build a custom page only when you need branded layouts or to embed <PricingTable for="organization" /> outside the OrganizationProfile shell.
if (evt.type === 'subscription.created') {
const { id, payer, items, status } = evt.data
if (payer.organization_id) {
const plan = items[0]?.plan?.slug
await db.orgSubscriptions.upsert({
where: { orgId: payer.organization_id },
create: {
orgId: payer.organization_id,
plan,
subscriptionId: id,
status,
},
update: { plan, subscriptionId: id, status },
})
}
}
if (evt.type === 'subscription.updated') {
const { id, payer, items, status } = evt.data
if (payer.organization_id) {
const plan = items[0]?.plan?.slug
await db.orgSubscriptions.update({
where: { orgId: payer.organization_id },
data: { plan, status },
})
}
}Use payer.organization_id (nested under payer, not a top-level org_id) when the subscription belongs to an organization. Do NOT use items.length as a seat count, seat limits are set at the plan level and there is only one active SubscriptionItem per payer per Plan.
Tier plans by seat cap so bigger orgs pay more:
| Plan | Slug | Seat cap |
|---|---|---|
| Startup | org:starter | 5 |
| Team | org:team | 10 |
| Business | org:business | 25 |
| Enterprise | org:enterprise | unlimited (requires B2B Authentication add-on) |
Define these via Dashboard → Billing → Plans → Organization Plans tab with Seat-based toggled on, or via clerk config patch with billing.plans. Use the org: prefix in slugs to disambiguate org plans from user plans in code (has({ plan: 'org:team' }) vs has({ plan: 'team' })). Seat caps above 20 and “unlimited” require the B2B Authentication add-on.
// WRONG, user has no active org, has() checks user subscription
const { has } = await auth()
if (!has({ plan: 'org:team' })) redirect('/billing')
// CORRECT, check orgId first
const { orgId, has } = await auth()
if (!orgId) redirect('/sign-in')
if (!has({ plan: 'org:team' })) redirect('/billing')