Overview
Trending Society is a white-label multi-tenant platform . Each tenant (organization) has completely isolated data while sharing the same infrastructure.
Current Status: 42 of 112 tenant-scoped tables have tenant_id columns (37.5% complete). Migration tracked in ENG-206.
Core Concepts
Tenant An organization with isolated data (e.g., “Golf Insider”, “Trending Society”)
Tenant Member A user belonging to one or more tenants
RLS PostgreSQL Row Level Security enforcing data isolation
White-Label Custom branding per tenant (subdomain, colors, logo)
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ MULTI-TENANT ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Tenant A (Golf Insider) Tenant B (Trending Society) │
│ ──────────────────────── ───────────────────────────── │
│ golf.trendingsociety.com app.trendingsociety.com │
│ Custom branding Default branding │
│ tenant_id = 'golf-123' tenant_id = 'ts-456' │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SHARED DATABASE (with RLS) │ │
│ │ │ │
│ │ SELECT * FROM content WHERE tenant_id = current_tenant() │ │
│ │ ↓ │ │
│ │ Returns ONLY current tenant's data │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Database Schema
Core Tenant Tables
-- Root tenant table
CREATE TABLE public .tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL ,
slug TEXT UNIQUE NOT NULL ,
domain TEXT ,
branding JSONB DEFAULT '{}' ,
created_at TIMESTAMPTZ DEFAULT now ()
);
-- User-to-tenant mapping
CREATE TABLE public .tenant_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id),
user_id UUID REFERENCES auth . users (id),
role TEXT DEFAULT 'member' ,
created_at TIMESTAMPTZ DEFAULT now (),
UNIQUE (tenant_id, user_id)
);
Adding tenant_id to Tables
Add Column
ALTER TABLE public . your_table
ADD COLUMN tenant_id UUID REFERENCES tenants(id);
Backfill Data
UPDATE public . your_table
SET tenant_id = 'default-tenant-uuid'
WHERE tenant_id IS NULL ;
Make Required
ALTER TABLE public . your_table
ALTER COLUMN tenant_id SET NOT NULL ;
Add RLS Policy
ALTER TABLE public . your_table ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Tenant isolation"
ON public . your_table
FOR ALL
USING (tenant_id = current_setting( 'app.tenant_id' )::uuid);
Table Classification
Every table falls into one of these categories:
Classification tenant_id? RLS? Example TENANT_SCOPEDYes Yes content, orders, usersGLOBAL_REFERENCENo No platforms, ai_model_configSYSTEM_INFRANo No circuit_breakers, checkpointsROOT_TENANTNo No tenants, tenant_membersIDENTITYNo RLS auth.users (Supabase managed)
112 tables are classified as TENANT_SCOPED and should have tenant_id. Currently only 42 do. This is a known gap.
Getting Tenant Context
Server Components (Next.js)
import { getTenantId } from '@/lib/tenant/server' ;
export default async function Page () {
const tenantId = await getTenantId ();
const { data } = await supabase
. from ( 'content' )
. select ( '*' );
// RLS automatically filters by tenant_id
return < ContentList data ={ data } />;
}
API Routes
import { getTenantId } from '@/lib/tenant/server' ;
export async function GET () {
const tenantId = await getTenantId ();
// Use tenantId for logging, metrics, etc.
// RLS handles data filtering
return Response . json ({ tenantId });
}
Edge Functions
// In Edge Functions, set tenant context explicitly
const { data , error } = await supabase . rpc ( 'set_tenant_context' , {
p_tenant_id: tenantId
});
// Now queries are scoped
const { data : content } = await supabase
. from ( 'content' )
. select ( '*' );
Handler Factory Pattern
For shared API logic, use the handler factory pattern:
// packages/api/src/shopify/handlers.ts
export function createKPIsHandler ( getTenantId : () => Promise < string >) {
return async function handler ( request : Request ) {
const tenantId = await getTenantId ();
// Get tenant's Shopify credentials
const { data : integration } = await supabase
. from ( 'tenant_integrations' )
. select ( 'credentials' )
. eq ( 'tenant_id' , tenantId )
. eq ( 'platform' , 'shopify' )
. single ();
// Fetch from Shopify using tenant's credentials
const kpis = await fetchShopifyKPIs ( integration . credentials );
return Response . json ( kpis );
};
}
// apps/dashboard/src/app/api/shopify/kpis/route.ts
import { createKPIsHandler } from '@trendingsociety/api/shopify' ;
import { getTenantId } from '@/lib/tenant/server' ;
export const GET = createKPIsHandler ( getTenantId );
This pattern keeps business logic in packages/ while apps only contain thin wrappers that inject tenant context.
Schema Security Boundaries
These rules are non-negotiable . Security incidents start here.
Schema Purpose Access RLS publicProduct data auth + anon + service_role Required systemInfrastructure service_role ONLY None eventsAudit logs service_role ONLY None
Invariants
No user access to system schema. Ever. No exceptions.
No user access to events schema. Create filtered VIEWs in public if needed.
events tables are append-only. No UPDATE. No DELETE.
Every public table has RLS enabled. Default deny.
Tenant Integrations
Each tenant can have their own integrations:
CREATE TABLE public .tenant_integrations (
id UUID PRIMARY KEY ,
tenant_id UUID REFERENCES tenants(id),
platform TEXT NOT NULL , -- 'shopify', 'linear', etc.
credentials JSONB NOT NULL , -- encrypted
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now (),
UNIQUE (tenant_id, platform)
);
Credentials are stored encrypted using Supabase Vault (pgsodium).
White-Label Branding
Tenants can customize their experience:
{
"branding" : {
"logo" : "/uploads/tenant-logo.svg" ,
"primaryColor" : "#1a73e8" ,
"accentColor" : "#4285f4" ,
"favicon" : "/uploads/tenant-favicon.ico"
}
}
Applied via CSS variables:
:root {
--primary : var ( --tenant-primary , #667eea );
--accent : var ( --tenant-accent , #764ba2 );
}
Testing Multi-Tenant
describe ( 'Multi-tenant isolation' , () => {
it ( 'should only return current tenant data' , async () => {
// Create data for tenant A
await supabase . rpc ( 'set_tenant_context' , { p_tenant_id: tenantA });
await supabase . from ( 'content' ). insert ({ title: 'Tenant A Content' });
// Switch to tenant B
await supabase . rpc ( 'set_tenant_context' , { p_tenant_id: tenantB });
const { data } = await supabase . from ( 'content' ). select ( '*' );
// Tenant B should not see Tenant A's data
expect ( data ). toHaveLength ( 0 );
});
});