Skip to main content
Back to Blog
How Tos & TutorialsJanuary 20, 2026

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.

LaunchKit Team

Building tools for makers

Multi-tenancy architecture guide for B2B SaaS with Supabase

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 LaunchKit

Written by

LaunchKit Team

We're a small team passionate about helping developers and entrepreneurs ship products faster. LaunchKit is our contribution to the maker community.

Related Articles