Charge per seat in Next.js, and handle the moment seats run out
Per-seat billing in Next.js: read seat state, gate the invite button, and branch on why invites are blocked. The three block reasons need three different answers.
In short
Per-seat billing is easy to meter and easy to lose money on. useSeatStatus returns memberCount, includedSeats and an inviteBlockReason, and that reason matters: seat_limit_reached wants an upgrade path, no_subscription wants checkout, and settings_user_limit_reached is not a billing wall at all.
Most per-seat billing tutorials are about incrementing a number. Wire member
count to Stripe's quantity field, add a seat when someone joins, remove one
when they leave. That part is mechanical and every boilerplate does it. Seats
are also the easy half of usage billing, since a seat count is a number you
already have - metering something that happens thousands of times a
day is the harder shape.
The part that decides whether per-seat billing makes you money is somewhere else: the moment an admin clicks "invite" and there is no seat left. Handle it well and that click is an upgrade. Handle it as a generic error toast and it is a support ticket, or nothing at all.
Before you start
A BuildBase project with the workspaces module and a plan that defines a seat allowance. Our own plans include 3, 8 and 50 team members on Launch, Grow and Scale, so there is always a real number to bump into.
Step one: read the seat state
useSeatStatus takes a workspace and tells you where it stands.
import { useSeatStatus } from '@buildbase/sdk/react';
function SeatBadge({ workspace }) {
const { memberCount, includedSeats, canInvite } = useSeatStatus(workspace);
return (
<p>
{memberCount} of {includedSeats} seats used
{!canInvite && ' - seat limit reached'}
</p>
);
}You should see something like "3 of 5 seats used" render for a workspace on a five-seat plan. What comes back:
| Return | Type | What it is |
|---|---|---|
memberCount | number | Current members |
includedSeats | number | Seats in the plan |
availableSeats | number | Remaining, or Infinity if unlimited |
canInvite | boolean | Whether invites are allowed right now |
inviteBlockReason | 'seat_limit_reached' | 'settings_user_limit_reached' | 'no_subscription' | null | Why invites are blocked, or null if they are not |
Step two: gate the invite control
Two things gate an invite button, and they are not the same thing. Permission decides whether this person may invite anyone at all. Seat state decides whether there is room.
import {
Permission,
useSeatStatus,
WhenPermission,
} from '@buildbase/sdk/react';
function InviteButton({ workspace }) {
const { canInvite, inviteBlockReason } = useSeatStatus(workspace);
return (
<WhenPermission permission={Permission.WORKSPACE_MEMBERS_INVITE}>
<button disabled={!canInvite}>Invite member</button>
{!canInvite && <BlockedNotice reason={inviteBlockReason} />}
</WhenPermission>
);
}A viewer sees no button. An admin on a full plan sees a disabled one with a reason next to it. Those are different states and they should look different.
Step three: branch on the reason, not the boolean
This is the decision the reference docs leave to you. inviteBlockReason
returns three codes, and collapsing them into one "upgrade your plan" dialog
gets two of the three wrong.
function BlockedNotice({ reason }) {
switch (reason) {
case 'seat_limit_reached':
// They want another seat and are willing to be sold one.
return <UpgradePrompt />;
case 'no_subscription':
// No plan at all. An upgrade dialog makes no sense here.
return <CheckoutPrompt />;
case 'settings_user_limit_reached':
// An admin-configured cap. Upgrading changes nothing.
return <AdjustLimitHint />;
default:
return null;
}
}seat_limit_reached is the revenue moment. Someone is actively trying to add a
teammate and the plan is what is stopping them, which is the most qualified
upgrade prompt you will ever show. Show the price of the seat, not a pricing
page. On our own plans that jump is concrete: Launch is $49 a month and carries
3 team members, Grow is $99 and carries 8. "Add a seat for the rest of this
cycle" converts. "Compare our plans" does not. That jump also moves the seat
line itself, and what the customer is billed for the switch depends on which
way it goes.
no_subscription looks similar and is not. There is no plan to upgrade from,
so an "upgrade" button points at nothing. That user needs checkout.
settings_user_limit_reached is the one that punishes a lazy switch. The wall
is a limit somebody on their own team configured, so selling them a bigger plan
does not remove it. Point them at the setting instead. Charge someone for a
plan that does not fix their problem and you get a refund request and a bad
week.
Step four: let the server be the truth
The hook is display state. Do the actual add through the workspaces API and let the server decide.
import { useSaaSWorkspaces } from '@buildbase/sdk/react';
const { addUser } = useSaaSWorkspaces();
await addUser(workspaceId, '[email protected]', 'editor');Two clients can hold a stale canInvite at the same moment and both think a
last seat is free. Only one of those invites can be right, and the server is
what settles it. The same split applies to
quota state in React: the hook is for
rendering, the server is for deciding. If workspaces themselves are the part
you have not built yet, that setup comes
first - seats are a property
of a workspace, so there is nothing to count without one.
The failure modes
Printing availableSeats unconditionally. On an unlimited plan it is
Infinity, and "4 of Infinity seats used" is a real thing that ships. Branch on
whether the number is finite before rendering it.
Treating canInvite as enforcement. It is a boolean for building UI. A
disabled button is not a permission check, and anything that matters gets
decided server-side when the member is actually added.
Showing the same dialog for all three reasons. Covered above. It is the one that costs money rather than just looking sloppy.
Forgetting seats go down too. Removing a member frees a seat, and the subscription should move in that direction as well as upward. A count that only ever grows is how a customer ends up paying, month after month, for three people who left in March.
I have not run this exact file end to end as one app. Each snippet matches the canonical SDK usage in our own docs and constants rather than being a variant written for this post.
npm i @buildbase/sdk