Supabase Multi-Tenancy in Production: Schema, RLS, and the Patterns That Actually Work
Running multi-tenant apps on Supabase is mostly a schema and RLS problem — until it isn't. Here are the patterns I use across production apps: tenant isolation, JWT claims, request-time config, middleware, and the gotchas that cost the most time.
Multi-tenancy is mostly a schema and RLS problem. The core idea is simple: every table gets a tenant_id, every policy filters by it, and the current tenant flows in from the request. The reality is full of edge cases — deleted tenants, cross-tenant admin queries, N+1 lookups, and migration pain nobody talks about until you're already in it.
These are the patterns I use when building multi-tenant apps on Supabase, and the things that cost me time.
One Project or Many?#
Before the schema, the real question is whether to put all tenants in one project or give each one their own.
Separate projects give you hard isolation, no shared connection limits, and no risk of one tenant's slow query degrading another's. The downside is operational overhead — if you have 50 tenants, you have 50 Supabase projects to manage, update, and monitor.
Row-level tenancy in a shared project makes sense when:
- You have tens to hundreds of tenants with the same schema
- Tenants share the same feature set and you want one codebase to serve all of them
- The RLS complexity is worth the operational simplicity
I use separate projects for my own apps — it's simpler at low tenant counts. For client SaaS work with many tenants, shared-project row-level tenancy is what I reach for. Everything below is about that pattern.
The Schema#
Every table that holds tenant-specific data gets a tenant_id column:
CREATE TABLE items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
The index on tenant_id is not optional:
CREATE INDEX ON items(tenant_id);
Without it, every RLS policy evaluation on this table is a sequential scan. On a small table you won't notice. On a table with 500k rows spread across 500 tenants, every query pays the full scan cost. The index is the difference between Postgres jumping to your tenant's rows and reading all of them.
Freelance
Need help with this?
I help startups and businesses ship web projects: migrations, new products, and performance fixes.
Get in touch →