Guides
Build guide

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.

Dharmendra Jagodana4 min read

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:

ReturnTypeWhat it is
memberCountnumberCurrent members
includedSeatsnumberSeats in the plan
availableSeatsnumberRemaining, or Infinity if unlimited
canInvitebooleanWhether invites are allowed right now
inviteBlockReason'seat_limit_reached' | 'settings_user_limit_reached' | 'no_subscription' | nullWhy 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
billing
workspaces
nextjs

Frequently Asked Questions

How do I check whether a workspace has seats left?

Call useSeatStatus(workspace). It returns memberCount, includedSeats, availableSeats, a canInvite boolean, and an inviteBlockReason code explaining why invites are blocked when they are.

Why does availableSeats come back as Infinity?

That is the documented value for a plan with unlimited seats. Render the count conditionally rather than printing it, or the UI reads "4 of Infinity seats used".

Is canInvite enough to enforce a seat limit?

No. It is display state for building the UI. The server enforces the actual limit when you add a member, so treat the hook as what the user sees and the server as what is true.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime