# Teams, invites and admin vs member for an app you built with a prompt

> No builder ships teams for your end users. Give every customer their own workspace, let them invite colleagues by email, and make admin vs member a rule the server enforces, not a hidden button.

- Source: https://www.buildbase.app/guides/teams-invites-and-roles-for-a-vibe-coded-app
- Section: Guides
- Author: Dharmendra Jagodana
- Published: 2026-10-27

## In short

To add teams to a vibe-coded app, give every customer a workspace of their own on first sign-in, let members invite colleagues by email with a role, and enforce that role where the data is read, on the server. BuildBase holds the workspaces, members and roles; your database keeps the data, scoped by workspace id.

Two people from the same company sign up for your app separately. Now you
have two accounts that should have been one, two sets of data that should
have been shared, and no way to merge them that does not involve you, a
database, and a Saturday. This is the teams problem, and it arrives with
the second customer rather than the hundredth.

No builder ships the answer for your end users. Lovable's changelog is full
of workspaces, roles, invites and SCIM, and every entry is about Lovable's
own workspaces for the people building the app. Its end-user sign-in docs
describe no teams, roles or invitations; Base44's cover sign-in methods and
no roles; Bolt, Replit and v0 document nothing here. All read on 27 October 2026. Whatever a prompt builds for teams, it builds from scratch, and what
it builds is the subject of every audit of a vibe-coded app we have read.

So this guide does it the other way round. The workspaces, the members, the
invitations and the roles live in BuildBase, where they already exist, and
your database keeps your data with one extra column. When it works: a new
customer lands in their own workspace the moment they sign in, invites a
colleague who gets an email and arrives in the same workspace with the role
they were given, sees a member list and a switcher, and cannot see, or
change, a single row that belongs to another company, however the request
is made.

What we ran on 27 October 2026: the workspace, invitation and role pieces
below are the ones the published guides on
[workspaces in an afternoon](/guides/multi-tenant-workspaces-in-an-afternoon),
[team invitations](/guides/team-invitations-nextjs) and
[roles per workspace](/guides/roles-and-permissions-per-workspace-nextjs)
are built on, and the data-isolation pattern is the one in
[per-org data isolation](/guides/per-org-data-isolation-nextjs). The
prompts are what we hand an agent. We have not run them inside a builder's
chat today.

> **Before you start:** An app with BuildBase sign-in working (the [v0
> guide](/guides/add-sign-in-teams-and-paid-plans-to-a-v0-app) or the [Lovable
> guide](/guides/add-login-teams-and-paid-plans-to-a-lovable-app) gets you
> there), and a BuildBase organization in Platform mode, which is the default. A
> second email address you control, for the invite.

## 1. Decide what a workspace is, in your product's words

A workspace is the thing a customer pays for and shares with colleagues.
Before any prompt, decide what your product calls it (team, company,
studio, school, client) and whether a person can belong to more than one.
That one decision shapes the switcher, the invitation email and the
settings screen, and an agent will guess it wrong if you do not say.

Two settings in the console back the decision, under the organization's
workspace settings:

- **Auto-create first workspace.** On by default: a new user lands in a
  workspace of their own on first sign-in, with no setup step. Leave it on
  unless every user is invited into an existing team and never starts one.
- **Can users create more workspaces.** On if a consultant can have one
  workspace per client; off if a person belongs to exactly one company.

What you should see: a decision written in one line ("a workspace is a
school; teachers belong to one; admins can create more"), and the two
switches set to match. Everything below uses your word.

## 2. The switcher and the first workspace

The SDK's `useSaaSWorkspaces()` returns every workspace the signed-in
person belongs to, the current one, and the calls to create and switch.
The switcher is one component you style; the data is already there. Prompt
for it:

```text
Add a <team> switcher to the app header using useSaaSWorkspaces() from @buildbase/sdk/react: list the <teams> the user belongs to, mark the current one, switch on click, and show "New <team>" if the organization allows users to create more.
Call fetchWorkspaces() once after sign-in and select the first <team> if none is selected.
Do not create <team>, membership or role tables in my database; BuildBase holds them. Every page that shows my data reads currentWorkspace.id from useSaaSWorkspaces() and uses it in the query.
```

What you should see: sign up with a fresh email and you are in a workspace
already, named for you, with the switcher showing one entry. If creating
more is allowed, create a second and switch; the pages re-query for the
new id. If the switcher is empty, the override in step 1 is off or the
organization is in Personal mode, and
[auto-created first workspace](/guides/auto-created-first-workspace-nextjs)
has the two-minute fix.

The last line of that prompt is the one that matters, and the next section
is about why.

## What an agent builds for teams when nobody tells it otherwise

Left to itself, an agent asked for "teams" writes a `teams` table, a
`memberships` table with a `role` column, and a page that lists members. It
is the textbook shape, and it is where every audit of a vibe-coded app we
have read finds the leak, so it is worth knowing before you prompt what
the generated version leaves out.

The invitation is usually a row inserted for an email address, with no
email sent, no pending state and no expiry, so the invited person either
never hears about it or arrives to find nothing. The role is checked on
the page that lists members and nowhere else; the routes that change data
trust whoever is signed in. The workspace id travels in the request body,
because that is the easiest place for the agent to put it, which means any
member can send another workspace's id. And the "current workspace" is
whatever the browser last said it was.

None of that is carelessness. A developer who runs a competing platform
said on Hacker News in August 2026 that the apps he sees from
non-technical founders are "all apps with multi-tenancy - database backed

- payment integrations - the works", a dance-school management tool, a
  classifieds site with paid upgrades. The builders ask for teams because
  their customers are teams, and the agent delivers the textbook, which was
  written for a tutorial with one trusted user. The three findings from the
  February 2026 audit (self-promotion to admin, a paid flag anyone could set,
  a dev endpoint left live) are the textbook meeting its first stranger.

What this guide does differently is move the parts that need to be right
(who is in which workspace, who may do what, how an invitation becomes a
member) into a system that already enforces them, and leave the agent one
job it can do well: filter your rows by an id the server already knows.

## 3. Your data, scoped by workspace: the part no SDK does for you

Your tables are yours, in Lovable Cloud, Supabase, Neon or wherever the
builder put them, and every row that belongs to a customer carries their
workspace id. BuildBase decides who is in which workspace; your queries
decide which rows a workspace sees, and no SDK can write those queries for
you. This is the step a prompt gets wrong most often, so it is the longest
one.

The rule has three parts, and all three have to hold:

1. **Every customer row has a `workspace_id` column**, set when the row is
   created, from the current workspace, on the server.
2. **Every read and write filters by it**, with the id taken from the
   session's workspace on the server, never from the request body.
3. **The database refuses what the query forgets.** Row-level security in
   Supabase or Postgres, or a check in the data layer that no route can
   skip.

Why "never from the request body" gets its own clause: a developer who
reviews AI-built code wrote on 25 October 2026 that the first thing he
looks for is "server routes that take a user id from the request body
instead of the session", alongside "tables without RLS, policies that say
true". A workspace id in the request body is the same bug: any member can
send another workspace's id and read its rows. The server knows which
workspace the session is in; it never needs to be told.

```text
Add a workspace_id column to every table that holds customer data (<tables>), filled on insert from the current workspace on the server. Filter every select, update and delete by workspace_id, taking the id from the server session's current workspace, never from the request body, query string or headers.
Add row-level security policies so a row is only readable and writable when its workspace_id matches the caller's current workspace. Keep the policies in a migration file I can read.
```

If your sign-in is BuildBase and your data is in Supabase, there is one
honest wrinkle: Supabase policies keyed to `auth.uid()` do not know a
BuildBase session. Either read and write through server routes that check
the session and query with a server credential scoped by the workspace id,
or mint a Supabase-shaped token on your server for browser queries. Both
are laid out in [the Lovable
guide](/guides/add-login-teams-and-paid-plans-to-a-lovable-app), section 7,
and [per-org data isolation](/guides/per-org-data-isolation-nextjs) shows
the server-route shape in Next.js.

What you should see, and this is the test that matters: sign in as a
member of workspace A, open a page that lists rows, and in the browser's
developer tools replay the request with workspace B's id in the body, the
query string and a header. Every attempt returns A's rows or nothing. Then
change the id in the page address, if the page carries one. Same result.
If any of those returns B's data, the filter trusts the client, and nothing
else in this guide helps until that is fixed.

> **Key takeaway:** BuildBase decides who belongs to which workspace. Your queries decide which
> rows a workspace sees, with the id from the session, never the request. Both
> halves have to be right, and the audits say the second half is the one that is
> usually wrong.

## 4. Invite a colleague by email

Members invite teammates by email and give each one a role. An address
with no account yet gets an email with a link; following the link is the
proof, so the person signs up and lands in the workspace without a
separate email-verification step. If the organization has switched
invitations off, the landing page says so before anyone is sent anywhere.

The SDK's built-in members screen does all of this, which is why the
prompt is mostly about not rebuilding it:

```text
Add a /settings page that opens the SDK's built-in workspace, members and billing screens. On the members screen, admins can invite by email with a role, change a member's role, remove a member, and see and withdraw pending invitations; members see the list read-only.
Use the SDK's screens and hooks (useWorkspaceInvitations, inviteMember); do not build an invitation table, an invitation email or an accept page of my own.
```

What you should see: invite your second email address as a member. The
email arrives from your sending domain with a link; open it in a private
window, sign up, and you land in the same workspace with the member role.
Back in the first window, the pending invitation has become a member. Then
invite the same address again: refused, with a named reason, because the
SDK's invitation errors carry a `code` and the server's fields (the seat
limit and the count when it is a seat limit, the retry delay when it is a
rate limit), and the screen says each in words.

Two things can refuse an invitation that look like bugs and are not. A
seat limit on the plan, which the members screen shows as "n of m seats"
through `useSeatStatus`, counting pending invitations the way the server
does. And an outsiders policy: an organization can block inviting people
from outside its own domains, or cap the role an outsider may hold, and
the error names that too. [Why an invited member cannot see the
workspace](/guides/invited-member-cannot-see-workspace) walks the other
failure, where the invitation worked and the data did not follow.

## 5. Admin and member: enforce the role where the data is

A role is a rule about what a teammate may do, and it is only a rule if the
server applies it. Owners and admins decide what each member can do; the
API refuses what the role does not allow; the interface merely hides what
would be refused. Generated apps get the third part and skip the second,
which is the bug every audit finds.

The February 2026 audit of three apps posted to Reddit found "a live
marketplace with real Stripe payments where any logged-in user could grant
themselves admin and hijack payment routing with a single request". The
admin button was hidden. The request behind it was not checked. One
request.

Roles in BuildBase exist at two levels: organization roles for your own
staff, and workspace roles for the customer's team, with defaults a
workspace can adjust and permissions you add for your product ("export
reports", "manage billing"). In the browser, the SDK ships the gates:

```tsx
import { WhenWorkspaceRoles, WhenPermission } from '@buildbase/sdk/react';

<WhenWorkspaceRoles roles={['owner', 'admin']}>
  <DeleteWorkspaceButton />
</WhenWorkspaceRoles>

<WhenPermission permission="reports.export">
  <ExportButton />
</WhenPermission>
```

And on the server, the same question asked of the session, not the
request:

```text
Every server route that changes <team> data or settings must check the caller's role or permission in the current workspace through the BuildBase server SDK, using the session, before doing the work, and return 403 with a short JSON error otherwise. Never read a role, a permission or an "isAdmin" value from the request body, query string or headers.
In the browser, wrap admin-only controls in <WhenWorkspaceRoles> or <WhenPermission> from @buildbase/sdk/react, as a convenience only; the server check is the rule.
```

Two levels, and it is worth knowing which you are prompting for. An
organization role is for your own staff: who at your company may see
every workspace, change plans, read the audit log. A workspace role is for
the customer's team: owner, admin, member, or the names your product uses,
with the defaults a workspace admin may adjust for their own team. The
permissions you add ("reports.export", "billing.manage") hang off workspace
roles, so a customer's owner decides which of their colleagues may export,
and your server asks that question of the workspace, not of a global
table. If you have one role column today, this is why it is not enough:
the same person can be an admin of one workspace and a member of another.

What you should see: as a member, copy the address of an admin-only page
and open it: the controls are gone. Then, from the developer tools, replay
an admin request (change a role, delete the workspace) with the member's
session. 403, every time, whatever the body says. If it succeeds, the
route checks nothing or checks the client, and that is the one-request
bug.

One honest caveat about timing: a role change reaches a signed-in user
within the hour, not instantly, because the SDK refreshes permissions on a
schedule rather than on every request. [Why permissions refresh hourly and
not instantly](/blog/why-permissions-refresh-hourly) is the trade, and
[a permission change not taking
effect](/guides/permission-change-not-taking-effect) is what to do when
you need it sooner. [What is RBAC](/blog/what-is-rbac) is the explainer if
"role" and "permission" are blurring together.

## 6. Switching, and the data that did not switch

The last bug is the quiet one. A person switches workspace in the
switcher, and a page still shows the old workspace's rows, because a hook
cached them or a query ignored the change. It looks like a leak and it is
usually a stale fetch.

```text
Every data hook and loader in the app depends on currentWorkspace.id from useSaaSWorkspaces(): when it changes, refetch, and clear any cached rows from the previous <team> before the new rows arrive. Never keep rows from one <team> in memory while another is selected.
```

What you should see: with two workspaces that have different data, switch
back and forth. Each page shows only the selected workspace's rows,
immediately, with no flash of the other's. [Stale data after a workspace
switch](/guides/stale-data-after-workspace-switch) covers the cache
details in Next.js.

## The screens you do not have to build

The settings screens for all of this ship with the SDK: workspace, members
(invite, pending invitations, roles, remove), billing, plan, usage,
devices, notifications and the rest, thirteen in all, in eight languages
including right-to-left ones. The `/settings` prompt in step 4 opens them
inside your app's own layout. You can draw your own members screen against
the same hooks (`useSaaSWorkspaces`, `useWorkspaceInvitations`,
`useSeatStatus`), and the only reason to is a design system the built-in
screens do not match; the behaviour is the same either way, and the
refusals (a seat limit, an outsider policy, a duplicate invitation) come
back as the same named errors.

Where the built-in screens earn their keep is the part nobody budgets for:
the pending-invitation list, the "withdraw" action, the role change that
has to be refused when it would leave a workspace with no owner, the
member removal that has to end that person's session on the server. A
generated members page does the first thing on that list and none of the
others.

## Where the builder's built-in sign-in wins

If your app is for one person, a notebook, a personal tracker, a tool for
a single operator, you do not need any of this, and the builder's built-in
sign-in with its users list is the better pick: zero setup, no switcher,
no roles to explain. Lovable's Users view, Replit's Users tab and Base44's
auth cover that case well, and adding workspaces to an app that will never
have two people in one account is weight for nothing.

The moment a second person from the same customer shows up, the maths
flips. Merging two personal accounts into one team later is the migration
every small SaaS dreads, and the audits say the generated version of teams
is where the data leaks. [What is multi-tenancy](/blog/what-is-multi-tenancy)
is the short explainer; [the best multi-tenant
backend](/compare/best-multi-tenant-backend) is the comparison if you want
to weigh the alternatives before committing.

## What breaks, and how you can tell

**A member sees another company's rows by changing an id.** The filter
takes the id from the request. Step 3, and nothing else matters until it
is fixed.

**The switcher is empty, or "New team" does nothing.** Auto-create is off,
the organization is in Personal mode, or "Can invite members" is disabled
under advanced overrides. Step 1's two switches.

**The invitation email never arrives.** It is sent from your organization's
sending domain, and that domain is not set up yet; [email landing in spam](/guides/transactional-email-landing-in-spam) is the guide. The
invitation itself exists; check pending invitations on the members screen.

**Invited, signed up, sees nothing.** The person signed up with a different
email than the one invited, or accepted in a browser that then switched to
a personal workspace. [Invited member cannot see the
workspace](/guides/invited-member-cannot-see-workspace) walks it.

**A member promoted themselves.** The role-change route checks the client,
or checks nothing. Step 5; replay the request to prove the fix.

**The role changed but the member still sees the old buttons.** Up to an
hour, by design. Sign out and in to force it, or follow
[permission change not taking effect](/guides/permission-change-not-taking-effect).

## Install

Add the SDK to the app you have, or let the CLI write the sign-in files
the switcher and members screen sit on:

```bash
npm i @buildbase/sdk
npx buildbase init --org-id <your 24-character org id>
```
