Building a B2B SaaS: Multi-Tenancy with Supabase in 2026
Complete guide to implementing multi-tenancy for B2B SaaS with Supabase. Covers team management, role-based access, data isolation, and seat-based billing patterns.
Building tools for makers

Why B2B Multi-Tenancy Matters
B2B SaaS is where the money is. Higher contract values, lower churn, and customers who actually pay. But B2B requires multi-tenancy—the ability for multiple organizations to use your app with complete data isolation.
This guide shows you how to implement multi-tenancy properly with Supabase and Next.js 15.
Multi-Tenancy Approaches
1. Shared Database, Row-Level Isolation (Recommended)
All tenants share one database. Each row has a team_id column, and Row Level Security (RLS) ensures data isolation.
- ✅ Simple to implement
- ✅ Cost-effective
- ✅ Easy to query across tenants (for admins)
- ❌ Requires careful RLS policies
2. Separate Schemas per Tenant
Each tenant gets their own database schema. More isolation, but harder to maintain.
3. Separate Databases per Tenant
Maximum isolation for enterprise clients. Complex to manage at scale.
For most B2B SaaS, option 1 (shared database + RLS) is the right choice. It scales to millions of tenants and is what we'll implement.
Database Schema Design
-- Teams table (the tenant)
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
plan TEXT DEFAULT 'free',
max_seats INTEGER DEFAULT 5,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Team memberships (users belong to teams)
CREATE TABLE team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
invited_at TIMESTAMPTZ DEFAULT now(),
joined_at TIMESTAMPTZ,
UNIQUE(team_id, user_id)
);
-- Example: Projects belong to teams
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Indexes for performance
CREATE INDEX idx_team_members_user ON team_members(user_id);
CREATE INDEX idx_team_members_team ON team_members(team_id);
CREATE INDEX idx_projects_team ON projects(team_id);Row Level Security Policies
RLS is the foundation of multi-tenancy in Supabase. Every query is automatically filtered to the user's team.
-- Enable RLS ALTER TABLE teams ENABLE ROW LEVEL SECURITY; ALTER TABLE team_members ENABLE ROW LEVEL SECURITY; ALTER TABLE projects ENABLE ROW LEVEL SECURITY; -- Helper function: Get user's team IDs CREATE OR REPLACE FUNCTION get_user_team_ids() RETURNS SETOF UUID AS $$ SELECT team_id FROM team_members WHERE user_id = auth.uid() $$ LANGUAGE sql SECURITY DEFINER STABLE; -- Teams: Users can only see teams they belong to CREATE POLICY "Users can view their teams" ON teams FOR SELECT USING (id IN (SELECT get_user_team_ids())); -- Team members: Users can see members of their teams CREATE POLICY "Users can view team members" ON team_members FOR SELECT USING (team_id IN (SELECT get_user_team_ids())); -- Projects: Users can CRUD projects in their teams CREATE POLICY "Users can view team projects" ON projects FOR SELECT USING (team_id IN (SELECT get_user_team_ids())); CREATE POLICY "Users can create team projects" ON projects FOR INSERT WITH CHECK (team_id IN (SELECT get_user_team_ids())); CREATE POLICY "Users can update team projects" ON projects FOR UPDATE USING (team_id IN (SELECT get_user_team_ids())); CREATE POLICY "Users can delete team projects" ON projects FOR DELETE USING (team_id IN (SELECT get_user_team_ids()));
Role-Based Access Control
Not all team members should have the same permissions. Here's how to implement RBAC.
-- Helper function: Check user's role in a team
CREATE OR REPLACE FUNCTION get_user_role(p_team_id UUID)
RETURNS TEXT AS $$
SELECT role FROM team_members
WHERE team_id = p_team_id AND user_id = auth.uid()
$$ LANGUAGE sql SECURITY DEFINER STABLE;
-- Only owners/admins can invite new members
CREATE POLICY "Admins can invite members"
ON team_members FOR INSERT
WITH CHECK (
get_user_role(team_id) IN ('owner', 'admin')
);
-- Only owners can delete the team
CREATE POLICY "Owners can delete team"
ON teams FOR DELETE
USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_id = teams.id
AND user_id = auth.uid()
AND role = 'owner'
)
);Team Invitation Flow
// app/api/team/invite/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/libs/supabase/server";
export async function POST(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { teamId, email, role = "member" } = await req.json();
// Check user has permission to invite
const { data: membership } = await supabase
.from("team_members")
.select("role")
.eq("team_id", teamId)
.eq("user_id", user.id)
.single();
if (!membership || !["owner", "admin"].includes(membership.role)) {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
// Check seat limit
const { data: team } = await supabase
.from("teams")
.select("max_seats")
.eq("id", teamId)
.single();
const { count } = await supabase
.from("team_members")
.select("*", { count: "exact", head: true })
.eq("team_id", teamId);
if (count && team && count >= team.max_seats) {
return NextResponse.json({ error: "Seat limit reached" }, { status: 400 });
}
// Create invitation (pending user)
// In production, send email with invite link
const { error } = await supabase
.from("team_invitations")
.insert({
team_id: teamId,
email,
role,
invited_by: user.id,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// TODO: Send invitation email via Resend
return NextResponse.json({ success: true });
}Seat-Based Billing with Stripe
B2B SaaS typically charges per seat. Here's how to sync team size with Stripe.
// Update Stripe quantity when team size changes
async function updateStripeSeats(teamId: string) {
const { data: team } = await supabase
.from("teams")
.select("stripe_subscription_id")
.eq("id", teamId)
.single();
if (!team?.stripe_subscription_id) return;
const { count } = await supabase
.from("team_members")
.select("*", { count: "exact", head: true })
.eq("team_id", teamId)
.not("joined_at", "is", null); // Only count active members
const subscription = await stripe.subscriptions.retrieve(
team.stripe_subscription_id
);
await stripe.subscriptions.update(team.stripe_subscription_id, {
items: [{
id: subscription.items.data[0].id,
quantity: count || 1,
}],
proration_behavior: "create_prorations",
});
}Team Context in Your App
// hooks/useTeam.ts
"use client";
import { createContext, useContext, useState, useEffect } from "react";
import { createClient } from "@/libs/supabase/client";
const TeamContext = createContext<{
team: Team | null;
teams: Team[];
switchTeam: (teamId: string) => void;
}>({ team: null, teams: [], switchTeam: () => {} });
export function TeamProvider({ children }: { children: React.ReactNode }) {
const [teams, setTeams] = useState<Team[]>([]);
const [currentTeamId, setCurrentTeamId] = useState<string | null>(null);
const supabase = createClient();
useEffect(() => {
async function loadTeams() {
const { data } = await supabase
.from("team_members")
.select("team:teams(*)")
.order("joined_at", { ascending: true });
const userTeams = data?.map(d => d.team).filter(Boolean) || [];
setTeams(userTeams);
// Default to first team or stored preference
const stored = localStorage.getItem("currentTeamId");
if (stored && userTeams.find(t => t.id === stored)) {
setCurrentTeamId(stored);
} else if (userTeams.length > 0) {
setCurrentTeamId(userTeams[0].id);
}
}
loadTeams();
}, []);
const switchTeam = (teamId: string) => {
setCurrentTeamId(teamId);
localStorage.setItem("currentTeamId", teamId);
};
const team = teams.find(t => t.id === currentTeamId) || null;
return (
<TeamContext.Provider value={{ team, teams, switchTeam }}>
{children}
</TeamContext.Provider>
);
}
export const useTeam = () => useContext(TeamContext);Common B2B Multi-Tenancy Patterns
1. Personal Workspace + Teams
Users get a personal workspace by default, and can create/join teams. Each has separate data.
2. Subdomain per Team
acme.yourapp.com routes to the Acme team. Great for enterprise feel.
3. Org Hierarchy
Enterprise customers often need nested structures: Organization → Teams → Projects.
Skip Months of Development
Multi-tenancy done right takes weeks of careful implementation. LaunchKit Teams includes:
- Complete team management UI
- Invitation flow with email
- Role-based access control
- Seat-based Stripe billing
- Team switcher component
- RLS policies pre-configured
Focus on your product, not reinventing team management.
Ready to ship faster?
LaunchKit gives you auth, payments, CRM, and everything you need to launch your SaaS in days, not months.
Get LaunchKitWritten by
LaunchKit TeamWe're a small team passionate about helping developers and entrepreneurs ship products faster. LaunchKit is our contribution to the maker community.
Related Articles

How Long It Really Takes to Launch a SaaS
Ignore the 'ship in a weekend' hype. Here's the honest timeline for launching a SaaS that can actually generate revenue.

Claude Code for SaaS Founders: Idea to Revenue
Complete playbook for solo founders using Claude Code to launch a SaaS. From validation to first paying customer in 3 weeks.

Build a SaaS MVP in 24 Hours with Claude Code
Step-by-step tutorial: Build a complete SaaS MVP in 24 hours using Claude Code, Next.js, and Supabase. Includes auth, payments, CRM, and deployment.