Next.js + Supabase Multi-Tenancy with RLS (Production Patterns)
Workspace-based multi-tenancy in Next.js + Supabase: schema, RLS policies, RBAC, and the pitfalls (recursive policies, RLS performance) that bite at scale.
Multi-tenancy is the architectural pattern that turns a single deployment into a product that serves hundreds or thousands of independent teams. Getting it right in Supabase requires deliberate schema design, RLS policies that are both correct and performant, and awareness of the failure modes that do not show up until production. This is the production-grade version.
Tenancy models
Three approaches exist for multi-tenancy in Postgres:
Row-level tenancy — all tenants share the same tables; a workspace_id column on every row identifies the owner. RLS policies enforce isolation. This is the standard approach for most SaaS and what this guide covers.
Schema-per-tenant — each tenant gets their own Postgres schema (tenant_abc.users, tenant_abc.projects). Stronger isolation, but Supabase tooling is built around the public schema, and managing hundreds of schemas operationally is complex.
Database-per-tenant — maximum isolation, maximum operational cost. Only justified for enterprise accounts with compliance requirements (HIPAA, SOC 2 with strict data residency). Not practical for most SaaS at early or mid-stage.
Row-level tenancy with RLS is the right starting point for nearly every SaaS using Supabase. It scales well, the tooling supports it, and the isolation guarantees are strong when the policies are written correctly.
Schema: workspaces and memberships
The core of the multi-tenant schema is two tables: workspaces (the tenant) and workspace_memberships (the join table between users and workspaces with a role).
create table workspaces (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create type workspace_role as enum ('admin', 'member', 'viewer');
create table workspace_memberships (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references workspaces(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role workspace_role not null default 'member',
invited_by uuid references auth.users(id),
accepted_at timestamptz,
created_at timestamptz not null default now(),
unique (workspace_id, user_id)
);
create index on workspace_memberships (user_id);
create index on workspace_memberships (workspace_id);
Every application table then carries a workspace_id foreign key:
create table projects (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references workspaces(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 (workspace_id);
RLS policies
Enable RLS on every table that holds tenant data:
alter table workspaces enable row level security;
alter table workspace_memberships enable row level security;
alter table projects enable row level security;
The core pattern for application tables: a user can access a row if they are a member of the workspace that owns it.
create policy "workspace members can read projects"
on projects for select
using (
exists (
select 1
from workspace_memberships wm
where wm.workspace_id = projects.workspace_id
and wm.user_id = auth.uid()
)
);
create policy "workspace admins can insert projects"
on projects for insert
with check (
exists (
select 1
from workspace_memberships wm
where wm.workspace_id = projects.workspace_id
and wm.user_id = auth.uid()
and wm.role = 'admin'
)
);
create policy "workspace admins can delete projects"
on projects for delete
using (
exists (
select 1
from workspace_memberships wm
where wm.workspace_id = projects.workspace_id
and wm.user_id = auth.uid()
and wm.role = 'admin'
)
);
The workspace_memberships table itself needs policies that allow users to see their own memberships:
create policy "users can read own memberships"
on workspace_memberships for select
using (user_id = auth.uid());
create policy "admins can manage workspace memberships"
on workspace_memberships for all
using (
exists (
select 1
from workspace_memberships wm
where wm.workspace_id = workspace_memberships.workspace_id
and wm.user_id = auth.uid()
and wm.role = 'admin'
)
);
RBAC in application code
The three roles — admin, member, viewer — enforce permissions at two layers: RLS for data access and application code for UI and server actions. A helper that returns the current user's role in a given workspace:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function getWorkspaceRole(
workspaceId: string
): Promise<"admin" | "member" | "viewer" | null> {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll() } }
);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const { data } = await supabase
.from("workspace_memberships")
.select("role")
.eq("workspace_id", workspaceId)
.eq("user_id", user.id)
.single();
return (data?.role as "admin" | "member" | "viewer") ?? null;
}
In a Next.js server component or server action:
const role = await getWorkspaceRole(workspaceId);
if (role !== "admin") {
throw new Error("Insufficient permissions");
}
Keep role checks in server code. Never trust a role value from a client-side request — always read it from the database in the server context.
Common pitfalls
Recursive RLS policies
The most dangerous bug in multi-tenant RLS: a policy on workspace_memberships that references workspace_memberships itself. Postgres evaluates RLS on every table access — including the ones inside the policy's EXISTS subquery. If the subquery hits the same table the policy is on, you get infinite recursion and a 500 error.
The solution: use a SECURITY DEFINER function that bypasses RLS for the membership lookup:
create or replace function get_my_workspace_ids()
returns setof uuid
language sql
security definer
stable
as $$
select workspace_id
from workspace_memberships
where user_id = auth.uid()
$$;
Then write policies that reference this function:
create policy "users can read own workspaces"
on workspaces for select
using (id in (select get_my_workspace_ids()));
The security definer function runs as the function owner (typically postgres), bypassing RLS for that specific internal call and breaking the recursion.
RLS performance at scale
Every row access triggers the RLS EXISTS subquery. On a table with millions of rows and dozens of concurrent users, this adds up. Three things keep it fast:
Indexes. Every foreign key that appears in an RLS EXISTS clause needs an index. workspace_memberships(workspace_id, user_id) and workspace_memberships(user_id) are the critical ones. Without them, every row access is a full table scan on the memberships table.
auth.uid() caching. Supabase caches auth.uid() within a request, but be aware that calling it in complex nested subqueries can degrade. Keep RLS expressions simple and flat.
Query structure in application code. Pass workspace_id explicitly in your queries rather than letting RLS filter after a broad select. select * from projects where workspace_id = $1 with RLS as a second guard is faster than select * from projects with RLS doing all the filtering.
Immutability triggers
Workspace IDs should not change after creation. Add an immutability trigger to prevent accidental updates:
create or replace function prevent_workspace_id_change()
returns trigger
language plpgsql
as $$
begin
if old.workspace_id is distinct from new.workspace_id then
raise exception 'workspace_id is immutable';
end if;
return new;
end;
$$;
create trigger enforce_workspace_id_immutability
before update on projects
for each row execute function prevent_workspace_id_change();
Apply the same trigger pattern to any table where the tenant association must not change.
Production-ready version
The schema above, all RLS policies, the RBAC helpers, the immutability triggers, invitation flows, and a multi-tenancy-aware admin panel are what NoStripeKit ships pre-configured with 13 migration files. The setup wizard runs supabase db push, applies all migrations, and configures RLS in one step.
FAQ
Should I use Supabase's built-in auth.uid() in all RLS policies?
Yes, always. Never pass user IDs as parameters that could be spoofed. auth.uid() is derived from the verified JWT that Supabase issues, so it is authoritative. Any RLS policy that uses a column or parameter for the user identity instead of auth.uid() is a potential security hole.
How do I handle workspace invitations before the invited user has an account?
Store pending invitations in a separate workspace_invitations table keyed by email, with a secure token. When the user signs up (or when they land on the invitation URL), match by email, create the membership, and mark the invitation as accepted. Do not create the membership before the user exists — auth.users foreign keys enforce this at the database level.
Can I mix RLS with the service-role key for admin operations?
Yes. The service-role key bypasses RLS entirely, which is correct for admin panel operations, background jobs, and webhook handlers that need to write across tenant boundaries. Use the anon key (via the Supabase client in the browser or server component) for user-facing data access where RLS should enforce isolation. Never expose the service-role key to the client.
What happens to tenant data when a workspace is deleted?
The on delete cascade on workspace_id foreign keys handles this — deleting a workspace deletes all its associated rows in child tables. Add a soft-delete column (deleted_at timestamptz) if you need to support undo or audit trails before permanent deletion. NoStripeKit includes audit log tables that capture delete events before cascade removes the data.