You launched with flat-rate pricing and it worked -- until a company with 40 users signed up for the plan you designed for individuals, and you realized you were giving away 39 seats for free.
That is when most B2B SaaS founders start thinking about per-seat billing. The idea is simple: charge per active team member. The implementation is less simple. Every time someone joins or leaves a team, Stripe needs to know about it. Mid-cycle seat changes create prorations. If you do not sync correctly, you either undercharge customers or charge them for people they removed last month.
This post walks through the decision of when per-seat billing makes sense, and how to wire it up in a Next.js SaaS that already has organizations and Stripe subscriptions.
Flat-rate pricing is easier to explain ("$49 per month, everything included") and converts better in early sales. Per-seat pricing makes sense when:
If every user in a team gets roughly equal value from your product, per-seat is usually the right model. If it is one primary user with occasional observers, flat-rate with a seat limit is simpler and causes less friction at signup.
When a user joins or leaves a team, you update the Stripe subscription's quantity to match the current active member count. Stripe handles the prorated charge automatically.
The key principle: the source of truth for member count is your database, not Stripe. You query the count, update Stripe, and handle the webhook to confirm.
Here is the membership change flow:
User invited and accepts --> orgMemberRepo.create() --> updateStripeQuantity()
User removed from org --> orgMemberRepo.delete() --> updateStripeQuantity()
The updateStripeQuantity function in your service layer:
async function updateStripeQuantity(orgId: string) {
const count = await orgMemberRepo.countActive(orgId);
const org = await orgRepo.findById(orgId);
await stripe.subscriptions.update(org.stripeSubscriptionId, {
items: [{ id: org.stripeItemId, quantity: count }],
proration_behavior: 'create_prorations',
});
}
Call this from the service layer, never from the route. The route validates, calls the service, and the service updates both your DB and Stripe.
Stripe fires customer.subscription.updated after every quantity change. You already handle this event to sync subscription status -- extend it to also write the current seat count back to the org record:
case 'customer.subscription.updated': {
const sub = event.data.object;
await orgRepo.update(sub.metadata.orgId, {
stripeSeats: sub.items.data[0].quantity,
stripePlan: sub.items.data[0].price.id,
});
break;
}
Storing stripeSeats lets you quickly check whether an org is over its confirmed seat count without re-querying Stripe on every request.
Once you have per-seat billing, you need an optional seat cap for plans that include a fixed number of seats (for example, "Team plan: up to 10 seats included"). Add a maxSeats field to your plan configuration and check it before allowing a new member invite:
async function inviteMember(orgId: string, email: string) {
const count = await orgMemberRepo.countActive(orgId);
const plan = getPlanConfig(org.stripePlan);
if (plan.maxSeats && count >= plan.maxSeats) {
throw new HttpError(402, 'Seat limit reached -- upgrade your plan to add more members');
}
// proceed with invite...
}
This pattern -- check in the service layer, throw HttpError, return a 402 to the client -- is the same approach used for usage limits elsewhere in the boilerplate. It keeps enforcement out of the UI and in one place.
A well-implemented per-seat billing flow is invisible when it works and clear when it does not. The team settings page should show:
You do not need to build the billing portal yourself. The Next.js SaaS boilerplate already wires up the Stripe Customer Portal, which handles invoice history, seat counts, and plan changes without any custom UI.
active, pending, removed) -- you only bill for active seatsThe multi-tenancy pattern in this boilerplate already handles role-based org membership. Per-seat billing is the natural next step once your B2B customers start bringing teams.
If you want to combine per-seat with plan-level feature gates (free tier: 2 seats, pro tier: unlimited), see usage limits with Stripe plan gating -- the same getPlanConfig() helper applies to both.
Per-seat billing follows a clear pattern once you see it: count active members, update Stripe, handle the webhook. The Next.js SaaS boilerplate gives you organizations, Stripe subscriptions, and a service layer that keeps this logic out of your routes -- so you are extending what already works, not building from scratch.
Get the boilerplate and ship the billing model that actually captures the value your product delivers to teams.