How to Build a Multi-Tenant SaaS Application
Every SaaS application is multi-tenant by definition. Multiple customers share the same application, and their data must be isolated. The question is not whether to implement multi-tenancy, but how.
We have built multi-tenant architectures for products like LancerSpace — where freelancers manage clients, proposals, and invoices in workspaces that must stay strictly isolated — and MindHyv, where entrepreneurs run entire businesses behind their own accounts. The patterns we use depend on the security requirements, scale expectations, and operational complexity we are willing to take on.
There are three fundamental approaches: shared database with row-level isolation, schema-per-tenant, and database-per-tenant. We default to the first one, and this post explains why.
The Three Multi-Tenancy Models
Shared database with Row-Level Security (RLS) is the simplest approach. All tenants share the same database and the same tables. Every table has a tenant_id column, and database-level policies ensure that queries only return rows belonging to the authenticated tenant.
Schema-per-tenant gives each tenant their own set of tables within a shared database. The table structures are identical, but tenant A’s invoices table is physically separate from tenant B’s invoices table.
Database-per-tenant is full isolation. Each tenant gets their own database instance. Complete data separation, independent scaling, but significant operational overhead.
Here is how they compare on the dimensions that actually matter:
| Dimension | Shared DB + RLS | Schema-per-tenant | DB-per-tenant |
|---|---|---|---|
| Data isolation | Logical (policy-enforced) | Physical (schema-level) | Physical (instance-level) |
| Tenant count | Thousands+ | Hundreds | Tens to low hundreds |
| Schema migrations | One migration, all tenants | One migration per schema | One migration per database |
| Operational complexity | Low | Medium | High |
| Cost per tenant | Very low | Low | High |
| Noisy neighbor risk | Higher | Medium | None |
| Compliance suitability | Most cases | Regulated industries | Strictest requirements |
For the vast majority of SaaS products — especially at the startup and growth stage — shared database with RLS is the right choice. It is what we use for every project built on Supabase, and it scales further than most people expect.

Shared Database with Row-Level Security
The core idea is simple: every row in every tenant-scoped table includes a tenant_id (or organization_id, workspace_id — the naming varies). Database policies enforce that users can only read and write rows where the tenant_id matches their own.
Here is how we set this up in PostgreSQL with Supabase.
First, the table structure. Every multi-tenant table gets a foreign key to the tenants table:
-- The tenants table (we usually call this "organizations" or "workspaces")
create table organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
created_at timestamptz default now()
);
-- Membership table — links users to organizations
create table organization_members (
id uuid primary key default gen_random_uuid(),
organization_id uuid references organizations(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text not null default 'member' check (role in ('owner', 'admin', 'member')),
created_at timestamptz default now(),
unique(organization_id, user_id)
);
-- A tenant-scoped table — every row belongs to an organization
create table projects (
id uuid primary key default gen_random_uuid(),
organization_id uuid references organizations(id) on delete cascade not null,
name text not null,
status text default 'active',
created_at timestamptz default now()
);
-- Index on tenant_id for every tenant-scoped table — this is critical
create index idx_projects_org_id on projects(organization_id);
That index on organization_id is not optional. Without it, the RLS policy forces a sequential scan on every query. With it, PostgreSQL efficiently filters rows by tenant before applying any other conditions.
Next, the RLS policies. These are the actual enforcement layer:
-- Enable RLS on the table
alter table projects enable row level security;
-- Helper function: get organizations the current user belongs to
create or replace function get_user_org_ids()
returns setof uuid
language sql
security definer
stable
as $$
select organization_id
from organization_members
where user_id = auth.uid()
$$;
-- SELECT policy: users can only see projects in their organizations
create policy "Users can view their organization's projects"
on projects for select
using (organization_id in (select get_user_org_ids()));
-- INSERT policy: users can only create projects in their organizations
create policy "Users can create projects in their organizations"
on projects for insert
with check (organization_id in (select get_user_org_ids()));
-- UPDATE policy: only admins and owners can update projects
create policy "Admins can update projects"
on projects for update
using (
organization_id in (
select organization_id from organization_members
where user_id = auth.uid()
and role in ('owner', 'admin')
)
);
-- DELETE policy: only owners can delete projects
create policy "Owners can delete projects"
on projects for delete
using (
organization_id in (
select organization_id from organization_members
where user_id = auth.uid()
and role = 'owner'
)
);
With these policies in place, your application code does not need to filter by tenant. Every query automatically returns only the rows the authenticated user is allowed to see. This is powerful because it eliminates an entire class of bugs — the kind where a developer forgets to add a WHERE organization_id = ? clause and accidentally leaks data across tenants.
// Application code — no tenant filtering needed
// RLS handles isolation automatically
const { data: projects } = await supabase
.from('projects')
.select('*')
.order('created_at', { ascending: false });
// This returns ONLY projects in the user's organization(s)
// There is no way to access another tenant's data through the API
Performance at Scale
The most common concern with shared-database multi-tenancy is performance. What happens when you have 10,000 tenants with millions of rows?
The answer: it works fine, as long as you index properly. PostgreSQL handles this efficiently because the organization_id index turns what would be a full table scan into a targeted index lookup. The query planner sees the RLS policy, recognizes the indexed column, and generates an efficient execution plan.
We run the following query regularly on our projects to verify that RLS policies are using indexes:
-- Check that your RLS policies are hitting indexes, not scanning
explain analyze
select * from projects
where organization_id = 'some-uuid-here';
-- You want to see "Index Scan" or "Bitmap Index Scan"
-- If you see "Seq Scan", you are missing an index
For tables with very high row counts (tens of millions), consider composite indexes that combine organization_id with commonly filtered or sorted columns:
-- Composite index for common queries within a tenant
create index idx_projects_org_status
on projects(organization_id, status);
create index idx_projects_org_created
on projects(organization_id, created_at desc);
These composite indexes mean that queries like “get all active projects in my organization, newest first” resolve entirely through the index without touching the heap.

Handling Workspace Switching
Most SaaS apps let users belong to multiple organizations or workspaces. A freelancer on LancerSpace might have their own workspace for solo work and also be a member of an agency workspace. They need to switch between them seamlessly.
The pattern we use: store the active workspace in the client state and pass it through the Supabase request when needed. The RLS policies already ensure they can only access organizations they belong to — the active workspace just determines which one they are currently viewing.
// SvelteKit layout — workspace switching
// src/routes/(app)/+layout.ts
export async function load({ params, locals }) {
const { supabase, session } = locals;
// Get all organizations the user belongs to
const { data: memberships } = await supabase
.from('organization_members')
.select(`
role,
organizations (id, name, slug)
`)
.eq('user_id', session.user.id);
// Determine active workspace from URL or default
const activeSlug = params.workspace;
const activeOrg = memberships?.find(
m => m.organizations.slug === activeSlug
)?.organizations ?? memberships?.[0]?.organizations;
return {
memberships,
activeOrg,
};
}
When to Consider Schema-Per-Tenant
Schema-per-tenant makes sense in specific scenarios. If your customers are in regulated industries — healthcare, finance, government — they may contractually require that their data is physically separated from other tenants. Schema isolation satisfies many compliance requirements without the full operational cost of separate databases.
The implementation is straightforward in PostgreSQL. Each tenant gets their own schema with identical table structures:
-- Create a new schema for a tenant
create schema tenant_acme;
-- Create tables within the tenant schema
create table tenant_acme.projects (
id uuid primary key default gen_random_uuid(),
name text not null,
status text default 'active',
created_at timestamptz default now()
);
-- Set search_path per connection to route queries to the right schema
set search_path to tenant_acme, public;
The problem is migrations. When you add a column, create an index, or modify a constraint, you need to apply that change to every tenant schema. With 10 tenants, this is manageable. With 500, it becomes a deployment risk.
We have never needed this pattern for our own products. Shared database with RLS satisfies the isolation requirements of every project we have built. But we have implemented it for client projects in healthcare where data residency requirements demanded it.

When to Consider Database-Per-Tenant
Database-per-tenant is the nuclear option. Maximum isolation, maximum operational cost. Each tenant has their own PostgreSQL instance, their own connection pool, their own backup schedule.
The use cases are narrow: enterprise SaaS where customers pay enough to justify dedicated infrastructure, applications with extreme compliance requirements (SOC 2 Type II, HIPAA with specific data handling provisions), or products where tenants have dramatically different load profiles and you need independent scaling.
We do not recommend this approach for startups. The operational overhead — managing hundreds of database instances, running migrations across all of them, monitoring each one — requires dedicated DevOps capacity that early-stage teams do not have. Start with shared database and RLS. Migrate specific high-value tenants to dedicated infrastructure later if the business requires it.
Common Pitfalls
Forgetting RLS on new tables. Every tenant-scoped table must have RLS enabled and policies defined. We enforce this through a checklist in our migration workflow: create table, add organization_id column, add index, enable RLS, add policies. Missing any step creates a security gap. We discuss migration workflows in more detail in our post on zero-downtime database migrations.
Not indexing the tenant column. We said it already, but it bears repeating. Without an index on organization_id, RLS policies degrade query performance from milliseconds to seconds as your data grows.
Bypassing RLS in application code. Supabase provides a service_role key that bypasses RLS for administrative operations. This key must never be exposed to the client. Use it only in server-side code for operations like tenant provisioning, admin dashboards, and background jobs.
// NEVER expose the service role key to the client
// Use it only in server-side code (API routes, server functions)
import { createClient } from '@supabase/supabase-js';
const adminClient = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY // Server-side only
);
// This bypasses RLS — use with extreme caution
const { data } = await adminClient
.from('organizations')
.select('*'); // Returns ALL organizations
Not testing isolation. Write tests that explicitly verify tenant isolation. Log in as user A, create data, log in as user B, confirm B cannot see A’s data. This sounds obvious but we have caught real bugs with these tests — subtle policy mistakes where a missing condition allowed cross-tenant reads.
Our Default Architecture
For every SaaS product we build — LancerSpace, MindHyv, Trackelio — we use the same foundation: Supabase with shared database multi-tenancy, RLS policies on every tenant-scoped table, composite indexes for performance, and thorough isolation testing.
This architecture handles the scale requirements of the products we build, keeps operational complexity low, and gives us strong data isolation without the overhead of managing separate schemas or databases for every tenant.
The pattern is simple enough to implement in a day, robust enough to run in production for years, and flexible enough to evolve as requirements change. That is the kind of architecture decision we look for — one that is good enough today and does not paint you into a corner tomorrow.
If you are building a SaaS product and need help designing your multi-tenancy architecture, reach out at hello@threshline.com. We have shipped this pattern across multiple products and can help you get it right from the start.