Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Per-Seat Billing in Next.js SaaS -- Charge Teams by Member Count With Stripe and Drizzle ORM

July 25, 2026
nextjsstripedrizzle-ormsaaspayments

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.

When to Switch From Flat-Rate to Per-Seat

Flat-rate pricing is easier to explain ("$49 per month, everything included") and converts better in early sales. Per-seat pricing makes sense when:

  • Your product's value scales directly with team size -- a project management tool, a sales CRM, a shared inbox
  • You are seeing large teams on plans priced for small ones, and per-seat captures that value
  • Your support and infrastructure cost per user is non-trivial

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.

The Core Implementation Pattern

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.

Handling the Stripe Webhook

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.

Gating the Invite Flow by Seat Limit

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.

What the User Sees

A well-implemented per-seat billing flow is invisible when it works and clear when it does not. The team settings page should show:

  • Current seat count and the next billing amount
  • A clear message when the seat limit is reached
  • The prorated charge or credit on the next invoice (pull this from Stripe's upcoming invoice endpoint)

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.

Before You Go Per-Seat -- A Quick Checklist

  • Your org module tracks member status (active, pending, removed) -- you only bill for active seats
  • Your Stripe subscription has a single quantity item, not multiple prices per tier
  • You have tested a mid-cycle add and remove in Stripe's test mode to verify proration behavior
  • Your webhook handler is idempotent -- Stripe can retry events, so double-processing a quantity update must not double-charge

The 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.

Ship Per-Seat Billing This Weekend

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.