Every B2B SaaS I ship uses the same multi-tenant shape: anorganizations row per customer company, amemberships table linking users to orgs with a role, and every business table carrying org_id with RLS enforcing isolation. Stripe customer_id lives on the organization, not the user.
Schema
create table organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
stripe_customer_id text unique,
created_at timestamptz default now()
);
create table memberships (
org_id uuid references organizations(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text check (role in ('owner','admin','member')),
primary key (org_id, user_id)
);
create table projects (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references organizations(id),
name text not null
);RLS Pattern
create policy "org members read projects"
on projects for select using (
org_id in (
select org_id from memberships where user_id = auth.uid()
)
);TypeScript Types
export type OrgRole = 'owner' | 'admin' | 'member';
export interface Membership {
org_id: string;
user_id: string;
role: OrgRole;
}
export interface Organization {
id: string;
name: string;
stripe_customer_id: string | null;
}Invitations: magic link or email token table withorg_id, expiry, and role. On accept, insert membership and delete token. Stripe Checkout passes client_reference_id=org_idso webhooks update the right tenant.
Invitation Flow
Generate a signed token tied to org_id and intended role. Email link lands on /invite/[token] server route that validates expiry, creates membership on accept, and redirects into onboarding. Never trust client-sent org_id without server verification.
Need this shipped in production, not just in a blog post? Start your MVP.
