Hierarchical • Scalable • Secure
Platform Owner (You)
Client Organization
Client Organization
Scales to 100s+
Each tenant's customers/users (isolated data)
JWT tokens, OAuth providers
super_admin → admin → manager → user
tenant_id in JWT claims
Supabase/Vercel serverless
Per-tenant quotas
Tenant isolation enforcement
Row Level Security per tenant
Every table references tenants
deleted_at for data recovery
id: uuid PK
name: text
slug: text UNIQUE
plan: enum
settings: jsonb
created_at: timestamp
deleted_at: timestamp
id: uuid PK
tenant_id: uuid FK
email: text
role: enum
profile: jsonb
last_login: timestamp
id: uuid PK
tenant_id: uuid FK
created_by: uuid FK
data: jsonb
created_at: timestamp
updated_at: timestamp
id: uuid PK
tenant_id: uuid FK
user_id: uuid
action: text
entity: text
changes: jsonb
created_at: timestamp
-- Enable RLS on all tenant tables
ALTER TABLE your_resources ENABLE ROW LEVEL SECURITY;
-- Tenant isolation policy
CREATE POLICY "tenant_isolation" ON your_resources
FOR ALL
USING (tenant_id = auth.jwt() ->> 'tenant_id');
-- Super admin bypass
CREATE POLICY "super_admin_access" ON your_resources
FOR ALL
USING (auth.jwt() ->> 'role' = 'super_admin');
-- Helper function for tenant context
CREATE OR REPLACE FUNCTION get_current_tenant_id()
RETURNS uuid AS $$
SELECT (auth.jwt() ->> 'tenant_id')::uuid;
$$ LANGUAGE sql SECURITY DEFINER;
Read replicas, connection pooling (PgBouncer)
Large tenants → dedicated schemas/DBs
Redis for sessions, tenant configs
Queue system for heavy operations
React (Lovable) → Vercel/Cloudflare Pages
Supabase (Auth + DB + Edge Functions)
n8n for workflows, webhooks, integrations
Stripe (per-tenant billing)
| Permission | Super Admin | Tenant Admin | Manager | User |
|---|---|---|---|---|
| Manage all tenants | ✅ | ❌ | ❌ | ❌ |
| View platform analytics | ✅ | ❌ | ❌ | ❌ |
| Manage tenant settings | ✅ | ✅ | ❌ | ❌ |
| Invite/manage users | ✅ | ✅ | ✅ | ❌ |
| Create/edit resources | ✅ | ✅ | ✅ | ✅ |
| View own data | ✅ | ✅ | ✅ | ✅ |
This is your "clients" table. Every business that signs up gets a row here.
CREATE TABLE tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, -- e.g., "acme-corp" plan TEXT DEFAULT 'free', -- free, pro, enterprise created_at TIMESTAMPTZ DEFAULT now() );
Every table that holds customer data needs a tenant_id column. This is how you keep data separate.
CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID REFERENCES tenants(id), -- 👈 THIS IS KEY name TEXT, price DECIMAL ); -- Same pattern for: orders, customers, invoices, etc.
This is the magic. RLS automatically filters data so users only see their own tenant's data — no code needed.
-- Turn on RLS
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Create the policy (Supabase version)
CREATE POLICY "Users see own tenant data" ON products
FOR ALL USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
);
When a user logs in, include their tenant_id in their auth token. Supabase does this via custom claims.
-- In Supabase: create a function that runs on login
CREATE FUNCTION set_tenant_claim() RETURNS trigger AS $$
BEGIN
-- Add tenant_id to the user's JWT
UPDATE auth.users SET raw_app_meta_data =
raw_app_meta_data || jsonb_build_object('tenant_id', NEW.tenant_id)
WHERE id = NEW.user_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Now your app code stays simple. Just query the table — RLS handles the filtering automatically.
// In your React app (with Supabase client)
const { data } = await supabase
.from('products')
.select('*');
// 🎉 Returns ONLY products for the logged-in user's tenant
// No WHERE clause needed!
Architecture designed for Supabase + React/Lovable stack • Scales from 1 to 1000s of tenants