An eight-step tutorial covering the organizations data model, invite flow, the four-role permission stack, RLS for org-scoped data, active-organization switching, and per-org Stripe billing — the canonical multi-tenant pattern for a solo SaaS founder.
Methodology. This tutorial synthesizes the canonical multi-tenant SaaS patterns from the Supabase, Clerk, and WorkOS documentation as of May 2026. SQL shapes match Supabase’s recommended RLS patterns at supabase.com/docs/guides/auth/row-level-security; the four-role permission model mirrors what Clerk Organizations and WorkOS expose by default. Numbers and APIs change — verify against the official docs before shipping. See also the multi-tenant RLS guide and the multi-tenancy primer.
Team accounts are the architectural fork in a SaaS’s life: the moment your data model shifts from “users own rows” to “organizations own rows, users belong to organizations.” Done at the right moment, it’s a two-week project that unlocks B2B sales. Done too early, it’s premature complexity that drags the entire product. Done too late, after thousands of per-user rows have accumulated, it’s a painful migration that touches every query in the codebase.
This guide walks the canonical pattern: when team accounts are actually warranted, the three-table schema the rest of the industry has converged on, the invite flow, the four-role permission model, and the RLS policies that gate every tenant table. The code is Next.js + Supabase but the data model and decision rules apply to any Postgres-backed SaaS.
The honest answer changes by audience. The decision rule:
The most common mistake is the third category: B2B SMB founders who treat orgs as a v2 feature. By the time the third customer asks “how do I add my cofounder?”, the user table is already the wrong shape and every query needs rewriting. If there’s any chance your customers are companies, build the schema with orgs from the start even if the v1 UI hides them behind a “personal workspace” default.
Three tables form the canonical multi-tenant model. organizations is the tenant root. organization_members is the join table between users and orgs, carrying the per-member role. Every tenant-scoped table (projects, documents, invoices, settings, etc.) carries an organization_id column and an RLS policy. The SQL:
-- Roles enum: the four-role canonical model.
create type organization_role as enum ('owner', 'admin', 'member', 'viewer');
-- Tenant root.
create table organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
stripe_customer text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id)
);
-- User <-> org membership with role.
create table organization_members (
organization_id uuid not null references organizations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role organization_role not null default 'member',
joined_at timestamptz not null default now(),
primary key (organization_id, user_id)
);
create index on organization_members (user_id);
-- Pending invitations.
create table organization_invites (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references organizations(id) on delete cascade,
email text not null,
role organization_role not null default 'member',
token_hash text not null unique,
invited_by uuid not null references auth.users(id),
expires_at timestamptz not null default now() + interval '7 days',
accepted_at timestamptz,
created_at timestamptz not null default now()
);
create index on organization_invites (email);
-- Example tenant-scoped table: projects.
create table projects (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references organizations(id) on delete cascade,
name text not null,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create index on projects (organization_id);
Four design decisions encoded here. The composite primary key on (organization_id, user_id) in organization_members guarantees a user can’t accidentally appear twice in the same org. The role lives on the join row, not on the user — a user can be an Owner in one org and a Viewer in another. The invite token is hashed in the database; the raw token only lives in the email link. And every tenant table indexes organization_id because every query filters by it.
The first user becomes the Owner. The flow runs as a server action invoked from your signup or onboarding screen. It creates the org row, inserts the creator into organization_members with role owner, and (optionally) creates a default Stripe customer attached to the org.
// app/actions/create-organization.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { stripe } from '@/lib/stripe';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { slugify } from '@/lib/text';
export async function createOrganization(formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('not authenticated');
const name = String(formData.get('name') ?? '').trim();
if (!name) throw new Error('name required');
const slug = await uniqueSlug(supabase, slugify(name));
const customer = await stripe.customers.create({
name,
metadata: { creator_user_id: user.id }
});
const { data: org, error } = await supabase
.from('organizations')
.insert({
name,
slug,
stripe_customer: customer.id,
created_by: user.id
})
.select()
.single();
if (error) throw error;
const { error: memberErr } = await supabase
.from('organization_members')
.insert({
organization_id: org.id,
user_id: user.id,
role: 'owner'
});
if (memberErr) throw memberErr;
revalidatePath('/');
redirect(`/${org.slug}`);
}
async function uniqueSlug(supabase: any, base: string): Promise<string> {
let candidate = base;
let suffix = 0;
while (true) {
const { data } = await supabase
.from('organizations')
.select('slug')
.eq('slug', candidate)
.maybeSingle();
if (!data) return candidate;
suffix += 1;
candidate = `${base}-${suffix}`;
}
}
Three things this flow gets right. The Stripe customer is created up front so the org always has a billing identity, even before the first paid plan. The slug uniqueness check guards against the second org named “Acme” collapsing onto the first. And the Owner-creation happens in the same logical transaction as the org-row creation; you never want an org row to exist with zero members.
Invites are the most-clicked surface in any team-account feature. The canonical pattern: generate a random token, hash it in the database, send the raw token in an email link, validate and accept on click.
// app/actions/invite-member.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { sendInviteEmail } from '@/lib/resend';
import { createHash, randomBytes } from 'node:crypto';
export async function inviteMember(formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('not authenticated');
const orgId = String(formData.get('organizationId') ?? '');
const email = String(formData.get('email') ?? '').toLowerCase().trim();
const role = String(formData.get('role') ?? 'member') as
'owner' | 'admin' | 'member' | 'viewer';
// Caller must be owner or admin of the target org.
const { data: caller } = await supabase
.from('organization_members')
.select('role')
.eq('organization_id', orgId)
.eq('user_id', user.id)
.single();
if (!caller || (caller.role !== 'owner' && caller.role !== 'admin')) {
throw new Error('forbidden');
}
const rawToken = randomBytes(32).toString('base64url');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const { data: invite, error } = await supabase
.from('organization_invites')
.insert({
organization_id: orgId,
email,
role,
token_hash: tokenHash,
invited_by: user.id
})
.select()
.single();
if (error) throw error;
const acceptUrl =
`${process.env.NEXT_PUBLIC_APP_URL}/invites/accept?token=${rawToken}`;
await sendInviteEmail({ to: email, acceptUrl, organizationName: '...' });
}
And the accept handler, which validates the token, attaches the inviter to the org, and marks the invite consumed:
// app/invites/accept/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createHash } from 'node:crypto';
export async function GET(req: Request) {
const url = new URL(req.url);
const rawToken = url.searchParams.get('token');
if (!rawToken) return NextResponse.redirect(new URL('/login', req.url));
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.redirect(
new URL(`/login?next=/invites/accept?token=${rawToken}`, req.url)
);
}
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const { data: invite } = await supabase
.from('organization_invites')
.select('*')
.eq('token_hash', tokenHash)
.is('accepted_at', null)
.gt('expires_at', new Date().toISOString())
.single();
if (!invite) return NextResponse.redirect(new URL('/invite-expired', req.url));
if (user.email?.toLowerCase() !== invite.email) {
return NextResponse.redirect(new URL('/invite-email-mismatch', req.url));
}
await supabase.from('organization_members').upsert({
organization_id: invite.organization_id,
user_id: user.id,
role: invite.role
});
await supabase
.from('organization_invites')
.update({ accepted_at: new Date().toISOString() })
.eq('id', invite.id);
return NextResponse.redirect(new URL('/', req.url));
}
The email side of the flow — the React Email template, the Resend SDK call, the from-address setup — is the same pattern as any transactional email. If you haven’t wired Resend yet, the magic-link tutorial under how to add magic-link auth with Supabase covers the integration.
The canonical model the industry has converged on is four roles: Owner, Admin, Member, Viewer. Each maps to a distinct surface of capabilities. Don’t reinvent the model; customers expect these labels and your permission tests are simpler when the role enum is small and fixed.
| Role | Billing | Org settings | Invite/remove | Content (read) | Content (write) |
|---|---|---|---|---|---|
| Owner | Yes | Yes | Yes | Yes | Yes |
| Admin | No | Yes | Yes (except owners) | Yes | Yes |
| Member | No | No | No | Yes | Yes |
| Viewer | No | No | No | Yes | No |
Encode the role on organization_members.role using the Postgres enum from Step 2. Avoid building a separate permissions table in v1 — if you ever need finer-grained controls (custom roles, per-resource permissions), that’s a v3 problem and the right abstraction will be obvious by then.
Permission checks are a one-line lookup. A small helper keeps the call sites readable:
// lib/permissions.ts
import { createClient } from '@/lib/supabase/server';
type Capability =
| 'org.manage_billing'
| 'org.manage_settings'
| 'org.invite_members'
| 'content.write'
| 'content.read';
const MATRIX: Record<Capability, ReadonlySet<string>> = {
'org.manage_billing': new Set(['owner']),
'org.manage_settings': new Set(['owner', 'admin']),
'org.invite_members': new Set(['owner', 'admin']),
'content.write': new Set(['owner', 'admin', 'member']),
'content.read': new Set(['owner', 'admin', 'member', 'viewer']),
};
export async function canUserInOrg(
userId: string,
orgId: string,
capability: Capability
): Promise<boolean> {
const supabase = await createClient();
const { data } = await supabase
.from('organization_members')
.select('role')
.eq('user_id', userId)
.eq('organization_id', orgId)
.maybeSingle();
if (!data) return false;
return MATRIX[capability].has(data.role);
}
Row-level security is what makes the multi-tenant model safe. Without it, any application bug that forgets the WHERE organization_id = ? clause leaks tenants to each other. With RLS enabled and policies in place, Postgres itself refuses to return rows the current user isn’t authorized for, regardless of what the application code asks for.
The pattern: a SQL helper function reads the active organization from JWT claims, and every tenant table has a policy that gates access to membership in that org. The deep version of this pattern is covered in the Supabase RLS for multi-tenant SaaS tutorial; the essential shape:
-- Helper: returns the active org_id from the request's JWT custom claim.
create or replace function auth.user_org_id()
returns uuid
language sql
stable
as $$
select nullif(
current_setting('request.jwt.claims', true)::json ->> 'org_id',
''
)::uuid;
$$;
-- Helper: is the current user a member of the given org?
create or replace function auth.user_is_member_of(org uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1
from organization_members om
where om.organization_id = org
and om.user_id = auth.uid()
);
$$;
-- Enable RLS and add the canonical policies for projects.
alter table projects enable row level security;
create policy "members can read projects"
on projects for select
using (auth.user_is_member_of(organization_id));
create policy "members can write projects"
on projects for insert
with check (
auth.user_is_member_of(organization_id)
and organization_id = auth.user_org_id()
);
create policy "members can update projects"
on projects for update
using (auth.user_is_member_of(organization_id));
create policy "admins can delete projects"
on projects for delete
using (
exists (
select 1 from organization_members om
where om.organization_id = projects.organization_id
and om.user_id = auth.uid()
and om.role in ('owner', 'admin')
)
);
Three things to internalize. First, every tenant-scoped table needs enable row level security — forgetting it on one table is the entire vulnerability. Second, the insert policy pins the row to auth.user_org_id() so members can’t create rows in orgs they belong to but haven’t activated. Third, role-specific operations (like delete) check the role on the membership row directly, not from JWT claims, because role changes from the database side need to take effect without re-issuing tokens.
Users who belong to multiple orgs need a way to switch the “active” one. Three storage options for the active-org pointer:
org_id in the auth token at sign-in. RLS policies read it via auth.user_org_id() in Step 6. Switching orgs requires reissuing the token, which is a single round-trip.org_id in a cookie; server-side handlers read it. Simpler than JWT claims but RLS policies have to receive the org_id through a session variable, which is more setup.active_organization_id on the user row. Simplest mental model; downside is the active org follows the user across devices in ways that aren’t always desired.For Supabase specifically, the JWT custom-claim path is the cleanest because RLS policies can already read claims. The switcher is a server action that calls auth.refreshSession() with the new claim:
// app/actions/switch-organization.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function switchOrganization(formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('not authenticated');
const newOrgId = String(formData.get('organizationId') ?? '');
// Verify membership before allowing the switch.
const { data: membership } = await supabase
.from('organization_members')
.select('organization_id, organizations(slug)')
.eq('organization_id', newOrgId)
.eq('user_id', user.id)
.single();
if (!membership) throw new Error('not a member of this organization');
// Update the user's app_metadata so the next JWT carries org_id.
await supabase.auth.updateUser({
data: { active_org_id: newOrgId }
});
// Force a session refresh so the new claim is in the next request.
await supabase.auth.refreshSession();
revalidatePath('/');
// @ts-expect-error nested select shape
redirect(`/${membership.organizations.slug}`);
}
The UI side is a dropdown in the top-nav listing every org the user belongs to, with the active one selected. On switch, the server action runs, the JWT refreshes, and the user is redirected into the new org’s scoped URL (typically /${orgSlug}/...). Path-based scoping makes URL sharing within a team unambiguous — everyone who lands on /acme/projects/123 sees the Acme org’s project 123, regardless of whose link they clicked.
The single most important billing decision in a multi-tenant SaaS: subscribe at the organization level, not the user level. The Stripe Customer object attaches to the org row, not the user row. Every Stripe webhook event maps to an org, every invoice belongs to an org, every seat counts against an org’s plan.
The reason matters: the Owner who pays may not be the same person every month (especially after employee turnover); the seat count is a property of the team, not any one person; cancellation should not stranded other members’ access during a plan switch. A user-level subscription forces awkward workarounds for all three.
Encoded in the schema: organizations.stripe_customer from Step 2. The checkout flow creates a Stripe Checkout Session with customer: org.stripe_customer, and the webhook handler maps every event back to the org:
// app/api/webhooks/stripe/route.ts (excerpt)
import { stripe } from '@/lib/stripe';
import { createServiceClient } from '@/lib/supabase/service';
async function onSubscriptionUpdate(sub: any) {
const supabase = createServiceClient();
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('stripe_customer', sub.customer)
.single();
if (!org) return;
await supabase.from('organization_subscriptions').upsert({
organization_id: org.id,
stripe_subscription: sub.id,
status: sub.status,
plan: sub.items.data[0].price.id,
seats: sub.items.data[0].quantity ?? 1,
current_period_end: new Date(sub.current_period_end * 1000).toISOString()
});
}
Per-seat billing fits naturally on top of this: the seat count is a derived value from SELECT count(*) FROM organization_members WHERE organization_id = ?, and you push updates to Stripe’s subscription item quantity when members are added or removed. The org row, the subscription, and the seat count all live in one consistent unit.
Premature team-account complexity is one of the most common reasons B2C apps stall in onboarding. If 99% of your users are individuals, “create an organization” before the first useful action is friction that costs you conversion. Build the schema multi-tenant from day one if you can; hide the team UI behind a feature flag or a default “personal workspace” until the second customer asks for it.
What does your app do when a logged-in user has zero org memberships? Three valid answers: force-create a personal workspace on first login (cleanest for B2B SMB); route them to an org-create screen (cleanest for B2B mid-market); or offer both options on a chooser screen (cleanest for products with mixed audiences). Pick one and never let the user state stall — an authenticated user with no active org is a dead end in your routing.
When a user with email @company.com signs up and another org already has @company.com members, what happens? Be opinionated. The two reasonable rules: auto-join the existing org as a Member pending Admin approval, or create a new org and prompt the user to merge later. Auto-joining is more delightful for the user; creating-new is safer for tenant isolation. Most B2B SaaS lean toward the auto-join-with-approval pattern because the alternative is a forest of duplicate orgs per company. Whichever you pick, document it and surface it in the UI so users aren’t surprised.
This is the migration founders most often regret not planning for. A SaaS that launched with per-user subscriptions, scaled to a few hundred customers, and then realized it needs per-org seat-based pricing faces a multi-week project: schema migration, Stripe customer/subscription migration, grandfathering existing per-user plans, communicating the change. The honest plan: design the schema with orgs from day one (so the migration is data-only, not schema-and-data), give existing per-user customers a long grandfather window, and prefer adding per-org plans as a new SKU rather than forcibly converting old ones.
Three honest options at the v1 build:
The decision rule: build on Supabase if your audience is SMB and you have the engineering bandwidth; use Clerk Organizations if you want zero-to-orgs in a day; reach for WorkOS only when SAML SSO and directory sync are confirmed customer requirements.
Team accounts are the architectural pivot from a per-user SaaS to a multi-tenant SaaS. The canonical pattern is three tables (organizations, organization_members, organization_invites), the four-role permission model (Owner, Admin, Member, Viewer), RLS policies on every tenant table gated by auth.user_is_member_of(), and Stripe billing attached to the org row rather than the user row. Ship the schema multi-tenant from day one even if v1 hides it behind a personal workspace; the alternative is a painful migration the moment your third B2B customer asks “how do I add my cofounder?”
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.