Guides
Build guide

How to add team invitations in Next.js, and decide when the seat starts billing

Team invitations in Next.js: a pending invite holds a seat from the moment it is sent. Why that is the right default, and what it does to the invoice at renewal.

Dharmendra Jagodana9 min read

In short

In BuildBase, a team invitation takes a seat when the email is sent, not when it is accepted, and holds it until it is accepted, declined, revoked or lapses at 14 days. Past a plan's included seats, an invitation pending at renewal is billed for the new period, and its credit for unused time lands on a later invoice.

Sending a team invitation is one function call. The decision behind it is not in the call: does the invited person start costing the workspace a seat when the email goes out, or when they click accept?

BuildBase takes the seat at send. A pending invitation holds it until it is accepted, declined, revoked or expires, and once a seat-priced workspace is past its included seats, that seat is billed. Most of this guide falls out of that one rule, including the part almost nobody thinks about until a customer asks why their annual invoice has a seat for someone who never joined.

Before you start

A Next.js app with the BuildBase session wired through, which is BuildBase on Next.js, and workspaces from multi-tenant workspaces in an afternoon. For anything about money, a plan with seat pricing. Invitations need @buildbase/sdk 0.0.71 or later, a server from release 22, and an email sender configured for the organization: without one, the invite route refuses with 503 before it writes anything.

When does an invitation take a seat?

When the email is sent. The server's seat count is active members plus live pending invitations, where live means unanswered and inside its 14 days. Every check and every bill reads that one count. The plan's member cap reads it, and so do the trial cap and the workspace settings' maxWorkspaceUsers ceiling. So does the Stripe seat quantity, on every path that sets it: checkout, a plan change, a member added or removed, and every invitation sent, declined, revoked or expired.

Key takeaway

A pending invitation is a seat. It counts against the cap before the invitee has opened the email, and once a seat-priced plan is past its included seats, it is on the bill as well.

Here is how a pending seat ends:

What happens to the invitationWhat happens to the seat
AcceptedThe new member takes it over; the count stays put
DeclinedReleased
RevokedReleased at once
14 days pass with no answerReleased by a sweep that runs every ten minutes

Two details from the server are worth knowing before you build on it. If the invitation email cannot be queued, the server deletes the row and answers 502 rather than leave a pending seat behind, because a seat for a mail nobody received is a wrong bill. And resending is not free. A resend mints a new link and sets the expiry to 14 days from now, so an invitation resent every fortnight holds its seat for as long as someone keeps clicking Resend.

We made this call in September, while building workspace invitations, and the console's own organization invitations make a useful contrast. Those rows cost nothing, so nothing sweeps them: an expired one keeps the status invited in the database until someone tries to use it. An invitation inside your app bills, so it got a sweeper, an inline expiry when someone accepts too late, and a row that refuses to exist without its email.

Why send-time is the one you want

Because the cap has to count pending invitations, and once it does, billing on accept would leave the cap and the invoice counting two different things.

Start with the cap, and suppose it ignored pending invitations. An admin on a workspace capped at three seats, with two members, invites five people. All five sends go through. The first to accept takes the last seat, and the other four follow a link from a team they were told to join and land on "This workspace has reached its member limit. Ask an admin to make room." The person who hits that wall has the least context and no power to fix it.

Counting pending invitations moves the refusal to where it belongs. With BuildBase the admin's second invitation answers 402, with the count and the limit on it, while the admin is still at the keyboard.

So the cap counts them. Should the bill? If it does not, an admin sees a full workspace on the team page and a smaller seat count on the invoice, and somebody gets to explain why the workspace was full of seats it was not paying for. One count, read by the cap and by Stripe, is the only version where the seat count the admin sees and the seat count on the invoice move together.

The other answer is defensible, though. Makerkit's per-seat billing docs recommend updating the seat quantity only when an invitation is accepted, "to avoid billing for users who never join", while its seat enforcement still counts pending invitations against the limit. If your admins routinely invite people who never turn up, that split costs them less. BuildBase does not offer it. There is one count and no setting to leave pending invitations out of the bill, so if accept-time billing is a hard requirement, that is a real gap.

Two things soften it. addUser makes someone who already has an account a member at once: no pending row, no pending seat, nothing to expire. It answers 404 for an address with no account, which is the job inviteMember exists for. And billable seats are members plus pending invitations minus the plan's included seats, never below zero, so a pending invitation inside the included allowance costs nothing.

Step 1: ship the members screen, if it is enough

The SDK's members screen already does the whole job: invite by email with a role, the pending list under the form with Resend and Revoke, and a seat block that counts pending invitations and says so.

'use client';

import { useSaaSAuth } from '@buildbase/sdk/react';

export function TeamButton() {
  const { openWorkspaceSettings } = useSaaSAuth();
  return (
    <button onClick={() => openWorkspaceSettings('users')}>Manage team</button>
  );
}

Click it and the members screen opens on the current workspace, with the invite form and any pending invitations listed under it. If that screen is all your product needs, skip to the renewal section. The invitations reference covers the invitee's side, including sign-up for someone with no account.

Step 2: make your own seat count agree with the server

The trap in a custom team page is one missing argument. useSeatStatus(workspace) counts the members on workspace.users and nothing else. Pending invitations are an option you pass, and the SDK's own members screen passes them. Leave it out and a workspace with three members and two pending invitations shows room for two more on a five-seat cap, while the server counts five and refuses the next invite.

// app/team/invite-members.tsx
'use client';

import { useState } from 'react';
import type { IWorkspace } from '@buildbase/sdk/react';
import {
  isInvitationError,
  useSaaSWorkspaces,
  useSeatStatus,
  useWorkspaceInvitations,
} from '@buildbase/sdk/react';

export function InviteMembers({ workspace }: { workspace: IWorkspace }) {
  const { inviteMember } = useSaaSWorkspaces();
  // One instance for the count and the form. Each call to the hook keeps its
  // own list, so a second instance would not see the row you just created.
  const { pendingCount, refresh } = useWorkspaceInvitations(workspace._id);
  const { memberCount, canInvite } = useSeatStatus(workspace, {
    pendingInvitations: pendingCount,
  });

  const [email, setEmail] = useState('');
  const [sending, setSending] = useState(false);
  const [notice, setNotice] = useState<string | null>(null);

  async function send() {
    setSending(true);
    setNotice(null);
    try {
      await inviteMember(workspace._id, email, 'editor');
      await refresh(); // the new row holds a seat now, not on accept
      setNotice(`Invitation sent to ${email}`);
      setEmail('');
    } catch (err) {
      if (isInvitationError(err) && err.code === 'SEAT_LIMIT_REACHED') {
        setNotice(
          `No seats left: ${err.currentUserCount} of ${err.limit} in use, counting pending invitations.`
        );
      } else {
        setNotice(
          err instanceof Error ? err.message : 'Could not send the invitation.'
        );
      }
    } finally {
      setSending(false);
    }
  }

  return (
    <section>
      <p>
        {memberCount} seats in use, {pendingCount} of them pending
      </p>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="demo@example.com"
      />
      <button onClick={send} disabled={!canInvite || sending || !email}>
        {sending ? 'Sending...' : 'Invite'}
      </button>
      {notice && <p>{notice}</p>}
    </section>
  );
}

What you should see: the count goes up by one the moment the invitation is sent, before anyone has opened the email.

Why handle the 402 when canInvite already disables the button? Because the hook is display state, and two admins can be looking at the same last seat. The refusal carries currentUserCount and limit, both counting pending invitations, so the message can say something specific. When the button is disabled for a reason other than a full workspace, charging per seat covers what to offer for each one.

From a server action or a route handler, the same invitation is bb().invitations.create(workspaceId, email, role) on the server client from the Next.js setup. It runs as the signed-in user, so the same permission check and the same seat count apply.

What happens to a pending invitation at renewal?

It is on the renewal invoice, if the workspace is past its included seats. Nothing in BuildBase recomputes seats at the period boundary: Stripe renews the seat line at whatever quantity it holds, and that quantity includes every invitation still live at that moment.

If the invitee then accepts, the charge was right all along. If the invitation is declined, revoked or expires instead, the quantity drops with a proration, and Stripe's own docs are blunt about what that means: "Negative prorations aren't automatically refunded". The credit for the unused time waits for a later invoice.

How much that matters comes down to the billing interval:

  • Monthly. The renewal charges a month of the seat, and the unused part comes back as a credit on the next monthly invoice. Small, and I would leave it alone.
  • Yearly. The renewal charges a year of the seat. If the invitation lapses two weeks later, the credit for the rest of the year rides on the next invoice the subscription produces, which can be the next renewal. That is the invoice that turns into a support ticket. Quarterly is the same shape, smaller.

Which invitations can this catch? Only ones sent, or resent, in the 14 days before the renewal date, because nothing older is still live. That makes the check cheap: an invitation whose validTill falls after the period end will still be pending when the period rolls over, unless somebody answers it first.

Step 3: show admins what renewal will bill

Do not auto-revoke. An invitation sent yesterday to a new hire is the one most likely to be accepted, and revoking it to dodge a prorated seat swaps a credit for a confused new hire. Show the admin instead, on plans billed less often than monthly, and put Revoke next to each row.

// app/team/renewal-notice.tsx
'use client';

import type { IWorkspace } from '@buildbase/sdk/react';
import {
  useSeatStatus,
  useSubscription,
  useWorkspaceInvitations,
} from '@buildbase/sdk/react';

export function RenewalInviteNotice({ workspace }: { workspace: IWorkspace }) {
  const { subscription } = useSubscription(workspace._id);
  const { invitations, pendingCount, revoke, busy, error } =
    useWorkspaceInvitations(workspace._id);
  const { billableSeats } = useSeatStatus(workspace, {
    pendingInvitations: pendingCount,
  });

  const sub = subscription?.subscription;
  if (!sub?.stripeCurrentPeriodEnd || sub.billingInterval === 'monthly') {
    return null;
  }
  // Inside the plan's included seats, a pending invitation costs nothing.
  if (billableSeats === 0) return null;

  const renewsAt = new Date(sub.stripeCurrentPeriodEnd);
  const billedAtRenewal = invitations.filter(
    (i) =>
      i.status === 'invited' &&
      i.validTill !== null &&
      new Date(i.validTill) > renewsAt
  );
  if (billedAtRenewal.length === 0) return null;

  return (
    <aside>
      <p>
        Still pending, and counted as seats when the plan renews on{' '}
        {renewsAt.toLocaleDateString()}:
      </p>
      <ul>
        {billedAtRenewal.map((i) => (
          <li key={i._id}>
            {i.email}{' '}
            <button
              onClick={() => revoke(i._id).catch(() => undefined)}
              disabled={busy === i._id}
            >
              {busy === i._id ? 'Revoking...' : 'Revoke'}
            </button>
          </li>
        ))}
      </ul>
      {error && <p>{error}</p>}
    </aside>
  );
}

What you should see: nothing, for most of the year. The notice can only appear in the last 14 days before a quarterly or yearly renewal, since no live invitation's clock runs further out than that, and then it lists exactly the invitations that will be on the invoice if nobody answers them.

I have not run these components against a live server while writing this. They use hooks and types the SDK exports from 0.0.71 on, and the seat wiring in step 2 is the same one the SDK's own members screen uses.

What breaks, and how to tell

The badge says there is room and the invite answers 402. useSeatStatus was called without pendingInvitations. The server counted the pending rows; your page did not.

The invite answers 502 and nothing shows as pending. The email could not be queued, so the server deleted the row rather than bill a seat for it. With no sender configured at all, the answer is 503 NO_SENDER_CONFIGURED, before anything is written. Set a sender up under Emails in the console first.

Bulk invites stop with a 429. A workspace can send sixty invitations an hour, and an organization two hundred invitation emails a day by default. Resending one invitation has a five-minute cooldown, and that refusal carries retryAfterSeconds so a button can count down instead of guessing.

A trial workspace on a seat-priced plan cannot invite past its included seats. During a trial the cap is the plan's included seats, pending invitations counted, and the refusal says so: "Trial includes 3 seats. Subscribe to add more members." for a plan that includes three.

An invitation that never stops billing. Someone keeps clicking Resend. Every resend restarts the 14 days, and the seat with them.

A plan change moves the seat line too. It recomputes billable seats against the new plan's included allowance, pending invitations counted, which is part of what a plan change does to the invoice.

The person you invited says it is not there. Check the address first. The invitation belongs to the address it was sent to, and accepting from a different account is refused with the invited address scrambled in the error. The errors section of the invitations reference lists every refusal and what it means.

Install

npm i @buildbase/sdk
workspaces
invitations
billing
nextjs

Frequently Asked Questions

Does a pending invitation count as a seat?

In BuildBase, yes. The seat count is active members plus live pending invitations, and both the seat caps and the Stripe seat quantity read that one count. Declining, revoking or expiry releases the seat; accepting hands it to the new member.

How long does a workspace invitation stay valid?

14 days from the last send. Resending sets the expiry to 14 days from now, so it also extends how long the seat is held. A sweep every ten minutes marks lapsed invitations expired and releases their seats.

Can I invite someone who does not have an account yet?

Yes, with inviteMember. The email link lands on your app, where they sign in or register and accept. addUser is the other call: it adds an existing account at once, with no pending state, and answers 404 when the address has no account.

Can I bill seats only when an invitation is accepted?

Not in BuildBase. There is one seat count and no setting to leave pending invitations out of the bill. If the people you add already have accounts, addUser skips the pending state entirely, and pending invitations inside the plan's included seats cost nothing.

Ship it

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

7-day free trialNo credit card requiredCancel anytime