---
# === IDENTITY ===
id: software/startup-dashboard/multi-tenant-architecture/2026
canonical_question: "How do I build multi-tenant startup dashboard — data isolation, per-startup customization, SaaS architecture?"
aliases:
  - "multi-tenant dashboard architecture setup"
  - "SaaS multi-tenancy with PostgreSQL RLS"
  - "per-tenant data isolation for startup dashboards"
  - "white-label dashboard platform architecture"
entity_type: execution_recipe
domain: software > startup-dashboard > multi-tenant-architecture
region: global
jurisdiction: global
temporal_scope: 2025-2026

# === VERIFICATION ===
last_verified: 2026-03-13
confidence: 0.88
version: 1.0
first_published: 2026-03-13

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "Supabase RLS performance improvements in pg15 (2025); Neon branching for tenant isolation GA (Q3 2025)"
  next_review: 2026-09-09
  change_sensitivity: high

# === CONSTRAINTS ===
constraints:
  - "Row Level Security (RLS) adds 5-15% query overhead on shared tables. For dashboards with > 50 tenants and complex aggregations, test query performance under load."
  - "Shared-schema multi-tenancy is cost-efficient but creates noisy-neighbor risk. One tenant's heavy query can degrade all tenants. Use connection pooling and per-tenant query timeouts."
  - "Per-tenant customization (themes, KPI selection, branding) must be stored as tenant config, never as code branches. Code forks per tenant are unmaintainable beyond 3 tenants."
  - "Tenant onboarding must be fully automated — manual database setup per tenant breaks at 10+ tenants. Use migration scripts and seed data templates."
  - "Data export and deletion must be tenant-scoped for GDPR compliance. A single DELETE without tenant_id WHERE clause is a catastrophic data loss event."

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User is building a single-tenant internal dashboard, not a SaaS platform"
    use_instead: "software/startup-dashboard/data-integration-architecture/2026"
  - condition: "User needs enterprise-grade multi-tenancy with dedicated databases per customer"
    use_instead: "software/system-design/multi-tenant-saas/2026"
  - condition: "User wants embedded analytics for their SaaS product, not a dashboard platform"
    use_instead: "Search knowledgelib.io for embedded analytics — no dedicated unit yet"

# === AGENT HINTS ===
inputs_needed:
  - key: tenant_count
    question: "How many tenants (startups/companies) will use the dashboard?"
    type: choice
    options: ["1-10", "10-50", "50-200", "200+"]
  - key: isolation_requirement
    question: "What level of data isolation is needed?"
    type: choice
    options: ["shared schema with RLS (cost-optimized)", "schema per tenant (moderate isolation)", "database per tenant (maximum isolation)"]
  - key: customization_level
    question: "What per-tenant customization is needed?"
    type: choice
    options: ["branding only (logo, colors)", "KPI selection and layout", "fully custom dashboards per tenant"]
  - key: technical_skill
    question: "What is the user's technical skill level?"
    type: choice
    options: ["semi-technical (can edit code)", "developer (can write code)", "team of developers"]

# === EXECUTION METADATA ===
execution:
  required_inputs:
    - name: "Authentication system"
      source: "user's auth provider"
      format: "OAuth2/OIDC provider (Auth0, Clerk, Supabase Auth)"
    - name: "Tenant data model design"
      source: "user's product requirements"
      format: "List of entities per tenant (KPIs, data sources, users)"
    - name: "Branding assets per tenant"
      source: "tenant onboarding"
      format: "Logo URL, primary color, company name"

  outputs:
    - name: "Multi-tenant dashboard platform"
      format: "deployed web application"
      description: "SaaS dashboard platform where each tenant sees only their data, with per-tenant branding, KPI customization, and user management"
    - name: "Tenant provisioning API"
      format: "REST API endpoint"
      description: "Automated tenant creation endpoint that provisions schema, seed data, and admin user"
    - name: "Tenant admin panel"
      format: "web UI"
      description: "Super-admin interface for managing tenants, monitoring usage, and handling billing"

  tools_required:
    - name: "PostgreSQL with RLS"
      purpose: "Multi-tenant data store with row-level security"
      tier: "free"
      cost: "$0 (Supabase free tier) to $25/month"
      alternatives: ["Neon with branching", "CockroachDB", "PlanetScale"]
    - name: "Next.js or Remix"
      purpose: "Full-stack web framework for dashboard UI"
      tier: "free"
      cost: "$0 (Vercel free tier) to $20/month"
      alternatives: ["SvelteKit", "Nuxt"]
    - name: "Auth0 or Clerk"
      purpose: "Multi-tenant authentication with organization support"
      tier: "free"
      cost: "$0 (7,500 MAU) to $23/1000 MAU"
      alternatives: ["Supabase Auth", "WorkOS", "Stytch"]

  credentials_needed:
    - service: "Supabase"
      type: "Service role key + anon key"
      where_to_get: "https://supabase.com (Project Settings > API)"
      free_tier_limits: "500MB database, 50K monthly active users"
    - service: "Auth0 or Clerk"
      type: "Client ID + Client Secret"
      where_to_get: "https://auth0.com or https://clerk.com"
      free_tier_limits: "Auth0: 7,500 MAU; Clerk: 10,000 MAU"

  estimated_duration: "15-25 hours for MVP; 40-60 hours for production-ready"
  estimated_cost: "$0 (all free tiers) to $150/month at 50 tenants"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/startup-dashboard/multi-tenant-architecture/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-13)"

# === RELATED UNITS ===
related_kos:
  depends_on:
    - id: "software/startup-dashboard/data-integration-architecture/2026"
      label: "Data integration patterns for dashboard data sources"
  feeds_into:
    - id: "software/startup-dashboard/dashboard-template-library/2026"
      label: "Pre-built templates that tenants can select"
  related_to:
    - id: "software/system-design/multi-tenant-saas/2026"
      label: "Multi-tenant SaaS architecture — tenant isolation models and trade-offs between pooled, schema-per-tenant and siloed designs"

# === SOURCES ===
sources:
  - id: src1
    title: "Multi-tenant data isolation with PostgreSQL Row Level Security"
    author: AWS
    url: https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src2
    title: "Shipping multi-tenant SaaS using Postgres Row-Level Security"
    author: Nile
    url: https://www.thenile.dev/blog/multi-tenant-rls
    type: industry_guide
    published: 2025-08-01
    reliability: high
  - id: src3
    title: "Multitenant SaaS Patterns — Azure SQL Database"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/azure/azure-sql/database/saas-tenancy-app-design-patterns
    type: official_docs
    published: 2025-09-01
    reliability: authoritative
  - id: src4
    title: "The developer's guide to SaaS multi-tenant architecture"
    author: WorkOS
    url: https://workos.com/blog/developers-guide-saas-multi-tenant-architecture
    type: industry_guide
    published: 2025-10-01
    reliability: high
  - id: src5
    title: "Data Isolation in Multi-Tenant SaaS: Architecture & Security Guide"
    author: Redis
    url: https://redis.io/blog/data-isolation-multi-tenant-saas/
    type: industry_guide
    published: 2025-07-01
    reliability: high
  - id: src6
    title: "Row Level Security for Tenants in Postgres"
    author: Crunchy Data
    url: https://www.crunchydata.com/blog/row-level-security-for-tenants-in-postgres
    type: industry_guide
    published: 2025-05-01
    reliability: high
---

# Multi-Tenant Dashboard Architecture

## Purpose

This recipe produces a multi-tenant startup dashboard platform where each tenant (company/startup) sees only their own data, with per-tenant branding, customizable KPI layouts, and isolated user management. The output is a deployed SaaS application using PostgreSQL Row Level Security for data isolation, automated tenant provisioning, and a super-admin panel for managing tenants — enabling you to serve multiple startups from a single codebase and database. [src1]

## Prerequisites
<!-- Agents: verify ALL prerequisites before executing. Missing prerequisites = failed execution. -->

- [ ] **PostgreSQL database** with RLS support — [Supabase](https://supabase.com) or [Neon](https://neon.tech) (both support RLS natively)
- [ ] **Authentication provider** with organization/tenant support — [Auth0](https://auth0.com), [Clerk](https://clerk.com), or Supabase Auth
- [ ] **Full-stack framework** — [Next.js](https://nextjs.org) 14+, [Remix](https://remix.run), or [SvelteKit](https://kit.svelte.dev)
- [ ] **At least one working single-tenant dashboard** — the multi-tenant layer wraps existing dashboard functionality
- [ ] **Deployment platform** — [Vercel](https://vercel.com), [Netlify](https://netlify.com), or [Cloudflare Pages](https://pages.cloudflare.com)
- [ ] **Domain routing strategy** decided — subdomain per tenant (acme.dashboards.io) or path-based (/app/acme/)

## Constraints
<!-- Hard rules. Agents: enforce throughout execution. -->

- RLS adds 5-15% query overhead on shared tables. For dashboards with > 50 tenants and complex aggregations, benchmark query latency under concurrent load. [src1]
- Shared-schema multi-tenancy creates noisy-neighbor risk. One tenant's heavy query degrades all tenants. Use connection pooling (PgBouncer) and per-tenant query timeouts. [src2]
- Per-tenant customization must be config-driven, never code branches. Forking code per tenant is unmaintainable past 3 tenants. [src4]
- Tenant onboarding must be automated — manual database provisioning breaks at 10+ tenants. Script tenant creation with migration and seed data. [src3]
- Data export and deletion must be tenant-scoped for GDPR. A DELETE without tenant_id WHERE clause is catastrophic. Use RLS policies to enforce even on admin operations.

## Tool Selection Decision

```
Which isolation model?
├── Budget-optimized AND tenants < 200
│   └── PATH A: Shared Schema + RLS — one database, tenant_id on every row
├── Moderate isolation AND tenants < 50
│   └── PATH B: Schema-per-Tenant — one database, separate schema per tenant
├── Maximum isolation AND enterprise customers
│   └── PATH C: Database-per-Tenant — dedicated database per customer
└── Hybrid (most common for growth-stage SaaS)
    └── PATH D: Tiered — RLS for free/basic, dedicated schema for paid, dedicated DB for enterprise
```

| Path | Isolation | Cost | Complexity | Best For |
|------|-----------|------|-----------|----------|
| A: Shared + RLS | Logical (row-level) | Lowest | Low | Startups, < 200 tenants |
| B: Schema-per-Tenant | Schema-level | Medium | Medium | Mid-market, 10-50 tenants |
| C: Database-per-Tenant | Full physical | Highest | High | Enterprise, compliance-heavy |
| D: Tiered Hybrid | Mixed | Varies | High | Growth-stage SaaS at scale |

## Execution Flow

### Step 1: Design the Multi-Tenant Data Model

**Duration**: 1-2 hours
**Tool**: SQL client

```sql
-- Core tenant management tables
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL, -- used in subdomain/path routing
  plan TEXT DEFAULT 'free', -- 'free', 'starter', 'pro', 'enterprise'
  branding JSONB DEFAULT '{}', -- { logo_url, primary_color, accent_color, favicon_url }
  settings JSONB DEFAULT '{}', -- { timezone, currency, fiscal_year_start, default_dashboard }
  created_at TIMESTAMPTZ DEFAULT NOW(),
  status TEXT DEFAULT 'active' -- 'active', 'suspended', 'churned'
);

CREATE TABLE tenant_users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  auth_user_id TEXT NOT NULL, -- ID from Auth0/Clerk/Supabase Auth
  email TEXT NOT NULL,
  role TEXT DEFAULT 'viewer', -- 'owner', 'admin', 'editor', 'viewer'
  invited_at TIMESTAMPTZ DEFAULT NOW(),
  last_active TIMESTAMPTZ
);

-- Example: tenant-scoped KPI data table
CREATE TABLE tenant_metrics (
  id SERIAL PRIMARY KEY,
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  metric_key TEXT NOT NULL, -- 'mrr', 'burn_rate', 'headcount', 'pipeline_value'
  metric_value NUMERIC(14,2),
  period DATE NOT NULL, -- first day of period
  granularity TEXT DEFAULT 'monthly', -- 'daily', 'weekly', 'monthly'
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Dashboard layout customization per tenant
CREATE TABLE tenant_dashboards (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name TEXT NOT NULL DEFAULT 'Main Dashboard',
  layout JSONB NOT NULL, -- widget positions, sizes, query bindings
  is_default BOOLEAN DEFAULT false,
  created_by UUID REFERENCES tenant_users(id),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_tenant_slug ON tenants(slug);
CREATE INDEX idx_tenant_users_tenant ON tenant_users(tenant_id);
CREATE INDEX idx_tenant_users_auth ON tenant_users(auth_user_id);
CREATE INDEX idx_metrics_tenant ON tenant_metrics(tenant_id);
CREATE INDEX idx_metrics_tenant_key ON tenant_metrics(tenant_id, metric_key);
CREATE INDEX idx_metrics_period ON tenant_metrics(tenant_id, period);
CREATE INDEX idx_dashboards_tenant ON tenant_dashboards(tenant_id);
```

**Verify**: All 4 tables created with indexes.
**If failed**: Check PostgreSQL version — RLS requires PostgreSQL 9.5+, gen_random_uuid() requires pgcrypto extension or pg13+.

### Step 2: Implement Row Level Security

**Duration**: 1-2 hours
**Tool**: SQL client

```sql
-- Enable RLS on all tenant-scoped tables
ALTER TABLE tenant_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_metrics ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_dashboards ENABLE ROW LEVEL SECURITY;

-- Create policies using session variable for tenant context
-- The application sets this via: SET app.current_tenant_id = '{tenant_uuid}';

-- tenant_users: users can only see members of their own tenant
CREATE POLICY tenant_users_isolation ON tenant_users
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- tenant_metrics: tenants can only access their own metrics
CREATE POLICY tenant_metrics_isolation ON tenant_metrics
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- tenant_dashboards: tenants can only access their own dashboards
CREATE POLICY tenant_dashboards_isolation ON tenant_dashboards
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Service role bypasses RLS for admin operations
-- In Supabase, the service_role key automatically bypasses RLS
-- For custom setups, create an admin role:
-- CREATE ROLE dashboard_admin BYPASSRLS;
```

Application-side tenant context:

```javascript
// Middleware: set tenant context on every request
async function setTenantContext(req, res, next) {
  const tenantId = req.user?.tenant_id; // from JWT claims
  if (!tenantId) return res.status(401).json({ error: 'No tenant context' });

  // Set PostgreSQL session variable — RLS policies read this
  await db.query(`SET app.current_tenant_id = '${tenantId}'`);
  next();
}

// Supabase alternative: use RLS with auth.uid()
// CREATE POLICY ... USING (tenant_id IN (
//   SELECT tenant_id FROM tenant_users WHERE auth_user_id = auth.uid()
// ));
```

**Verify**: Insert test data for two tenants. Query as Tenant A — verify Tenant B data is invisible. Attempt cross-tenant INSERT — verify it fails.
**If failed**: Check that RLS is ENABLED (not just policies created). Run `SELECT tablename, rowsecurity FROM pg_tables WHERE tablename LIKE 'tenant_%';`

### Step 3: Build Tenant Provisioning

**Duration**: 2-3 hours
**Tool**: Node.js / API endpoint

```javascript
// POST /api/admin/tenants — create new tenant
async function provisionTenant({ name, slug, ownerEmail, plan = 'free' }) {
  // 1. Create tenant record
  const tenant = await db.query(`
    INSERT INTO tenants (name, slug, plan, branding, settings)
    VALUES ($1, $2, $3, $4, $5)
    RETURNING id
  `, [name, slug, plan,
    JSON.stringify({ logo_url: '', primary_color: '#2563eb', accent_color: '#1e40af' }),
    JSON.stringify({ timezone: 'UTC', currency: 'USD', fiscal_year_start: 1 })
  ]);
  const tenantId = tenant.rows[0].id;

  // 2. Create owner user (linked to auth provider)
  const authUser = await authProvider.createUser({ email: ownerEmail });
  await db.query(`
    INSERT INTO tenant_users (tenant_id, auth_user_id, email, role)
    VALUES ($1, $2, $3, 'owner')
  `, [tenantId, authUser.id, ownerEmail]);

  // 3. Create default dashboard layout
  await db.query(`
    INSERT INTO tenant_dashboards (tenant_id, name, layout, is_default)
    VALUES ($1, 'Overview', $2, true)
  `, [tenantId, JSON.stringify(DEFAULT_DASHBOARD_LAYOUT)]);

  // 4. Seed sample metrics (optional — helps onboarding)
  await seedSampleMetrics(tenantId);

  // 5. Send welcome email
  await sendWelcomeEmail(ownerEmail, { name, slug, loginUrl: `https://${slug}.dashboard.io` });

  return { tenantId, slug };
}
```

**Expected output**: New tenant created with owner user, default dashboard, and sample data.
**Verify**: Log in as new tenant owner — dashboard loads with sample data, branding defaults applied.
**If failed**: Check auth provider user creation. Verify tenant_id propagation through the provisioning chain.

### Step 4: Build Per-Tenant Customization

**Duration**: 2-3 hours
**Tool**: Next.js / React

```javascript
// Tenant branding middleware — loads tenant config from DB or cache
async function loadTenantBranding(slug) {
  const cached = await redis.get(`tenant:${slug}:branding`);
  if (cached) return JSON.parse(cached);

  const result = await db.query(
    'SELECT branding, settings FROM tenants WHERE slug = $1 AND status = \'active\'',
    [slug]
  );
  if (!result.rows[0]) throw new Error('Tenant not found');

  const config = {
    branding: result.rows[0].branding,
    settings: result.rows[0].settings
  };

  await redis.setex(`tenant:${slug}:branding`, 300, JSON.stringify(config)); // 5-min cache
  return config;
}

// Dashboard layout engine — renders widgets from tenant config
function renderDashboard(layout, metrics) {
  return layout.widgets.map(widget => ({
    id: widget.id,
    type: widget.type, // 'kpi_card', 'line_chart', 'bar_chart', 'table'
    position: widget.position, // { x, y, w, h } grid coordinates
    config: {
      metric_key: widget.metric_key,
      title: widget.title,
      format: widget.format, // 'currency', 'percentage', 'number'
      comparison_period: widget.comparison_period // 'prev_month', 'prev_year'
    },
    data: metrics.filter(m => m.metric_key === widget.metric_key)
  }));
}
```

Tenant settings page schema:

```json
{
  "branding": {
    "logo_url": "https://...",
    "primary_color": "#2563eb",
    "accent_color": "#1e40af",
    "favicon_url": "https://..."
  },
  "dashboard_defaults": {
    "widgets": ["mrr", "burn_rate", "runway", "headcount", "pipeline"],
    "layout": "2x3_grid",
    "refresh_interval_seconds": 300
  },
  "data_sources": {
    "accounting": { "provider": "quickbooks", "connected": true },
    "crm": { "provider": "hubspot", "connected": false }
  }
}
```

**Verify**: Change tenant branding — logo and colors update on next page load. Add/remove dashboard widgets — layout persists across sessions.
**If failed**: Check Redis cache invalidation. Verify branding JSON schema matches frontend component props.

### Step 5: Build Tenant Routing and Authentication

**Duration**: 2-3 hours
**Tool**: Next.js middleware

```javascript
// Subdomain routing: acme.dashboard.io → tenant_slug = 'acme'
// middleware.ts (Next.js)
export function middleware(request) {
  const hostname = request.headers.get('host');
  const slug = hostname.split('.')[0]; // 'acme' from 'acme.dashboard.io'

  if (slug === 'app' || slug === 'www') {
    return NextResponse.next(); // marketing site
  }

  // Rewrite to tenant-scoped route
  return NextResponse.rewrite(
    new URL(`/tenant/${slug}${request.nextUrl.pathname}`, request.url)
  );
}

// Auth: verify user belongs to the resolved tenant
async function verifyTenantAccess(authUserId, tenantSlug) {
  const result = await db.query(`
    SELECT tu.role FROM tenant_users tu
    JOIN tenants t ON tu.tenant_id = t.id
    WHERE tu.auth_user_id = $1 AND t.slug = $2 AND t.status = 'active'
  `, [authUserId, tenantSlug]);

  if (!result.rows[0]) throw new Error('Access denied: user not in tenant');
  return result.rows[0].role;
}
```

**Verify**: Access two different tenant subdomains — each shows different branding and data. Attempt to access Tenant B as Tenant A user — verify 403 response.
**If failed**: Check DNS wildcard configuration (*.dashboard.io). Verify middleware slug extraction.

### Step 6: Deploy and Monitor

**Duration**: 1-2 hours
**Tool**: Vercel or Cloudflare Pages

- Configure wildcard DNS: `*.dashboard.io → deployment`
- Set environment variables: DATABASE_URL, AUTH_SECRET, REDIS_URL
- Create super-admin panel at admin.dashboard.io for tenant management
- Set up monitoring: per-tenant query latency, error rates, storage usage
- Configure alerts: tenant provisioning failures, RLS policy violations, storage > 80%

**Verify**: Provision a new tenant via admin panel — full onboarding flow works end-to-end.
**If failed**: Check DNS propagation (can take up to 48 hours for wildcard). Verify SSL certificate covers wildcard domain.

## Output Schema

```json
{
  "output_type": "multi_tenant_dashboard_platform",
  "format": "deployed SaaS application",
  "components": [
    {"name": "tenant_provisioning", "type": "api", "description": "REST endpoint for automated tenant creation with DB setup, auth, and seed data", "required": true},
    {"name": "rls_isolation", "type": "database", "description": "PostgreSQL RLS policies ensuring zero cross-tenant data access", "required": true},
    {"name": "branding_engine", "type": "config", "description": "Per-tenant logo, colors, and favicon with cached resolution", "required": true},
    {"name": "dashboard_customizer", "type": "ui", "description": "Drag-and-drop widget layout with per-tenant persistence", "required": true},
    {"name": "subdomain_routing", "type": "middleware", "description": "Wildcard subdomain resolution to tenant context", "required": true},
    {"name": "admin_panel", "type": "ui", "description": "Super-admin view for tenant management, usage monitoring, and billing", "required": true}
  ],
  "tenant_capacity": "1-200 tenants on shared schema; scale with tiered isolation",
  "data_source": "PostgreSQL with Row Level Security"
}
```

## Quality Benchmarks

| Quality Metric | Minimum Acceptable | Good | Excellent |
|---------------|-------------------|------|-----------|
| Data isolation | RLS on all tenant tables | + integration tests per release | + automated penetration testing |
| Provisioning time | < 60 seconds | < 15 seconds | < 5 seconds (fully automated) |
| Query latency (p95) | < 2 seconds | < 500ms | < 200ms |
| Customization scope | Branding only | + widget layout | + custom data sources + formulas |
| Tenant onboarding | Manual admin setup | Self-service signup | + Stripe billing + auto-provisioning |

**If below minimum**: Check RLS policy coverage — every table with tenant_id MUST have RLS enabled. Query latency above 2s usually means missing indexes on tenant_id columns.

## Error Handling

| Error | Likely Cause | Recovery Action |
|-------|-------------|----------------|
| Cross-tenant data leak in query | RLS not enabled on table or missing policy | Audit ALL tables: `SELECT tablename, rowsecurity FROM pg_tables;` — enable RLS on any missing |
| Tenant provisioning timeout | Auth provider rate limit or DB connection pool exhaustion | Implement retry with backoff; increase connection pool size |
| Subdomain not resolving | DNS wildcard not configured or SSL cert missing | Add *.domain CNAME record; request wildcard SSL from provider |
| Session variable not set (RLS blocks all rows) | Middleware failed to set app.current_tenant_id | Add error handling: if no tenant context, return 401 instead of empty data |
| Noisy neighbor degrading all tenants | One tenant running expensive aggregation query | Add per-tenant query timeout (SET statement_timeout); implement query cost limits |
| Branding not updating after change | Redis cache not invalidated | Invalidate cache key on branding update: `redis.del('tenant:${slug}:branding')` |

## Cost Breakdown

| Component | Free Tier (< 5 tenants) | Growth (5-50 tenants) | Scale (50-200 tenants) |
|-----------|-----------|-----------|----------|
| Database (Supabase) | $0 (500MB) | $25/mo (8GB) | $75/mo (50GB) |
| Hosting (Vercel) | $0 | $20/mo | $20/mo |
| Auth (Clerk) | $0 (10K MAU) | $25/mo | $100/mo |
| Redis (Upstash) | $0 (10K cmds/day) | $10/mo | $30/mo |
| **Total** | **$0** | **$80/mo** | **$225/mo** |

## Anti-Patterns

### Wrong: Using application-level filtering instead of RLS
Relying on `WHERE tenant_id = ?` in every query means one missed WHERE clause leaks all tenant data. Application bugs, ORM defaults, or raw SQL queries without the filter create immediate cross-tenant data exposure. [src1]

### Correct: Enforce isolation at the database level with RLS
PostgreSQL RLS policies apply automatically to every query, regardless of how it's constructed. Even if application code forgets the tenant filter, the database enforces isolation. Defense in depth. [src6]

### Wrong: Forking codebase per tenant for customization
Starting with "just a few custom features for our biggest customer" turns into unmaintainable code branches. By tenant 5, every bug fix requires patching N branches, and deployments take hours. [src4]

### Correct: Build a configuration-driven customization engine
Store all per-tenant differences as config (branding JSON, widget layouts, feature flags). One codebase, one deployment, N configurations. Use a tenant_settings table, not git branches.

## When This Matters

Use when building a dashboard platform that will serve multiple startups or companies from a single codebase. Critical when moving from "internal tool" to "SaaS product" — the inflection point is typically 3+ external users requesting access. This recipe builds the multi-tenant infrastructure layer — for the actual dashboard content, use the domain-specific dashboard recipes (finance, sales, people ops).

## Related Units

- [Data Integration Architecture](/software/startup-dashboard/data-integration-architecture/2026) — data pipeline patterns for dashboard sources
- [Dashboard Template Library](/software/startup-dashboard/dashboard-template-library/2026) — pre-built templates tenants can select
- [Notification Automation Rules](/software/startup-dashboard/notification-automation-rules/2026) — per-tenant alert configuration
- [Financial Operations Dashboard](/software/startup-dashboard/financial-operations-dashboard/2026) — example single-tenant dashboard to multi-tenant-ify
