A multi-tenant SaaS runs one app and one database for every customer and keeps each customer's data apart with a tenant id on every row. In a B2B product the tenant is usually a team, so the architecture needs accounts, memberships with roles, permissions checked in the database, and invitations that can't be forged or reused.
The sign-in page is the easy part. The hard part comes after it: who belongs to which team, what each person may do there, and how a stranger's email address becomes a member without opening a way into someone else's data. Below, each piece is shown as shipanysaas builds it on Supabase, with file names, so you can copy the shape or check your own.
How does multi-tenancy work?
Every customer shares the same code and database, and every row a customer owns carries their tenant id. A rule that runs on every query, not each developer's memory, keeps one tenant from seeing another's rows.
In a Supabase app that rule is row-level security. The browser and phone app query the database directly, so the database checks each request: is the signed-in user a member of the account in this row's account_id? If not, the row doesn't exist for them. Your code still filters by account, but a forgotten filter returns nothing instead of another customer's data. Supabase RLS explained covers the policies themselves.
What is the difference between multi-tenant and single-tenant?
Multi-tenant means all customers share one deployment and one database, separated by a tenant id. Single-tenant means each customer gets their own database, or their own whole deployment.
| Multi-tenant (shared database) | Single-tenant (database per customer) | |
|---|---|---|
| Isolation | A rule on every query, such as row-level security | Separate databases or servers |
| Migrations and backups | Run once | Run once per customer |
| Cost per new customer | Close to nothing | A new database or deployment |
| Cross-customer reporting | One query | Gather from every database |
| Typical fit | Self-serve SaaS, small and mid-size teams | Contracts that demand physical separation or data residency |
Most SaaS products start multi-tenant and move a single large customer to dedicated infrastructure only when a contract requires it. If the tenant id is on every row from day one, that move is an export, not a rewrite.
Supabase multi-tenant architecture: the tenant is the account
The kit keeps both kinds of tenant in one accounts table and points everything else at it. Here is the shape, from apps/web/supabase/schemas:
auth.users (Supabase Auth)
|
| sign-up trigger creates one personal account with the same id
v
accounts (03-accounts.sql)
|-- personal account is_personal_account = true, id = auth.users.id
'-- team account is_personal_account = false, slug, primary_owner_user_id
^
| account_id on every tenant row
|
|-- accounts_memberships (05) one row per person per team
| user_id -> auth.users
| account_role -> roles.name
| primary key (user_id, account_id)
|
|-- invitations (07) team accounts only
| email, role -> roles.name, invited_by, invite_token
| expires_at = 7 days, unique (email, account_id)
|
'-- billing_customers, subscriptions, orders, notifications, your tables
roles (04) role_permissions (06)
name, hierarchy_level (unique) role -> roles.name
owner = 1 permission (app_permissions enum):
member = 2 roles.manage, billing.manage,
settings.manage, members.manage,
invites.manage
- Personal accounts. A trigger creates one for each user at sign-up, with the same id as the user in
auth.users. - Team accounts. Shared workspaces with a name, a slug for the URL (
/home/<slug>) and a primary owner.create_team_accountcreates the team and the owner's membership in one call, so a team never starts without its owner.
Because every tenant table points at accounts, a user's own data and their team's data follow one pattern. Someone can start alone and join teams later without moving anything. Billing attaches to the account too, so a team's subscription belongs to the team, not to whoever typed in the card. If your product has no use for personal workspaces, NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY=true sends users straight to a team.
The role lives on the membership row (simplified from 05-memberships.sql):
create table public.accounts_memberships ( user_id uuid not null references auth.users on delete cascade, account_id uuid not null references public.accounts (id) on delete cascade, account_role varchar(50) not null references public.roles (name), primary key (user_id, account_id) );
One row per person per team means the same person can own one team and be a plain member of another. A single role column on the users table can't express that.
Role-based access control: roles, ranks and permissions
Role-based access control means your code asks "does this person's role allow this action?" instead of checking people one by one. The seed (17-roles-seed.sql) creates two roles:
| Role | Rank | Permissions |
|---|---|---|
owner | 1 | All five |
member | 2 | settings.manage, invites.manage |
There is no admin role out of the box. To add one, write a migration that inserts it into roles and role_permissions; ranks are unique, so move member down a level to make room. The app checks permissions and ranks rather than role names, so a new role is a migration, not a code change.
Permissions are checked by the database. has_permission(user_id, account_id, permission) runs inside row-level security policies: changing or deleting a pending invitation needs invites.manage, and uploading a team logo needs settings.manage. Pages read the same permissions to show or hide controls, but a hidden button is a courtesy. The policy is the lock.
Ranks cover what a permission alone can't:
- The primary owner can act on any member, and nobody can remove or demote the primary owner.
- Everyone else needs
members.manageand can only act on members ranked below them (can_action_account_member). - Nobody can invite someone to a role above their own, or move a member into one.
Why not put the role in the JWT?
Supabase's custom claims and RBAC guide adds a user_role claim to the access token with an auth hook. That suits app-wide roles. In a multi-tenant SaaS the role depends on which team you are looking at, so one claim per user doesn't fit. Tokens also go stale: Supabase's row-level security docs warn that a removed team membership won't show in auth.jwt() until the user's token refreshes. Reading memberships from the table applies a removal on the next request.
Team invitations, step by step
Invitations are where hand-built versions tend to leak, because an invite link is a way into another tenant. The kit's flow, from packages/features/team-accounts and the /join routes:
- Someone with
invites.manageinvites up to five emails at once, each with a role. The server action checks the permission and the rank, runs any custom invitation policies, and refuses addresses already on the team. - Only server code writes the invitation. Signed-in users have no insert grant on
invitations, so the browser can't create one. After its checks pass, the action callsadd_invitations_to_accountwith the admin client. - Each invitation is a row with the email, role, sender, a random token and an expiry 7 days out. An email can be invited to a team once, and a trigger refuses invitations to personal accounts.
- The email links to
/join/acceptwith the token and a signature: an HMAC made on the server with a key derived fromSUPABASE_SECRET_KEY. - A valid signature means a one-click join. The route makes a fresh Supabase sign-in link for the invited address: a magic link if the person has an account, an invite link that creates one if not. Without a valid signature, the link still works, but only through normal sign-up or sign-in.
/joinchecks the email. The invitation's address must match the signed-in user's, ignoring case. If it doesn't, the page says the invitation was not found or has expired.- Accepting is one database function.
accept_invitationadds the membership with the invited role and deletes the invitation; an expired token fails. On a per-seat plan, the seat count is then set to the new member count.
Edge cases, handled on purpose:
- Expired links. From the members page you can renew a pending invitation for another 7 days, change its role or delete it.
- Leaked tokens. Team members can't read the token column through the API, and the token alone can't sign anyone in without the signature.
- The wrong person clicking. An invitation only works for the address it was sent to.
- Business rules.
packages/features/team-accounts/src/server/policies/policies.tsis a registry for rules like "the team must have an active subscription". One is written as an example; none are switched on.
Ownership, leaving and deleting
- Transfer. The primary owner can hand the team to an existing member after entering a one-time code sent by email.
- Delete. The primary owner can delete the team, also with an emailed code, when
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true. Codes work once and are revoked after 5 wrong attempts. - Leave. Any member except the primary owner can leave.
Test the requests your UI never sends
Clicking through invites by hand only proves the happy path. The holes are in requests your UI never makes, and the kit's pgTAP files team-accounts, memberships, invitations, update-membership, delete-membership and transfer-ownership make them. invitations.test.sql, for example, tries to insert an invitation directly as the owner, a plain member, a member whose custom role holds invites.manage, and an outsider, and checks that all four fail. It also checks that no signed-in user can read the token column. Playwright specs in apps/e2e/tests/team-accounts and apps/e2e/tests/invitations cover the pages. How the kit keeps one customer's data away from another's lists the rest.
FAQ
Is Supabase good for multi-tenant SaaS?
Yes, for the shared-database model. Postgres row-level security enforces the tenant rule on every query, and Supabase Auth supplies the signed-in user the policies check. You still design the account, membership and role tables yourself, or start from a kit that has them.
Can one person be in several teams with different roles?
Yes. The role is stored on each membership, so someone can own one team and be a member of another. The account switcher moves between their personal account and their teams.
What if someone signs in with a different email than the one invited?
The invitation is tied to the address it was sent to, so it won't match. The emailed link signs them in as the invited address; otherwise they need to sign in with that address, or you can invite the one they actually use.
Do I need an admin role?
Not to start. Owner and member cover a small team. Add an admin with a migration when you need someone who can manage members without touching billing.
The full reference is Teams and invitations in the docs. To click through the members page yourself, the live demo has a sample team, Acme Inc, with members and two pending invitations.