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. [src1]
Prerequisites
- PostgreSQL database with RLS support — Supabase or Neon (both support RLS natively)
- Authentication provider with organization/tenant support — Auth0, Clerk, or Supabase Auth
- Full-stack framework — Next.js 14+, Remix, or SvelteKit
- At least one working single-tenant dashboard — the multi-tenant layer wraps existing dashboard functionality
- Deployment platform — Vercel, Netlify, or Cloudflare Pages
- Domain routing strategy decided — subdomain per tenant (acme.dashboards.io) or path-based (/app/acme/)
Constraints
- 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
Create the 4-table schema: tenants (with branding and settings JSONB), tenant_users (with auth provider linkage and roles), tenant_metrics (with tenant-scoped KPI storage), and tenant_dashboards (with per-tenant widget layout JSONB).
Verify: All 4 tables created with indexes. If failed: Check PostgreSQL version — RLS requires 9.5+, gen_random_uuid() requires pgcrypto or pg13+.
Step 2: Implement Row Level Security
Duration: 1-2 hours · Tool: SQL client
Enable RLS on all tenant-scoped tables. Create USING and WITH CHECK policies that read the tenant context from a session variable (app.current_tenant_id). Application middleware sets this variable on every request from the authenticated user's JWT claims.
Verify: Insert test data for two tenants. Query as Tenant A — Tenant B data is invisible. Attempt cross-tenant INSERT — verify it fails. If failed: Run SELECT tablename, rowsecurity FROM pg_tables; to confirm RLS is enabled.
Step 3: Build Tenant Provisioning
Duration: 2-3 hours · Tool: Node.js / API endpoint
Create a provisioning endpoint that: creates tenant record with branding defaults, creates owner user linked to auth provider, generates default dashboard layout, seeds sample metrics for onboarding, and sends welcome email.
Verify: Log in as new tenant owner — dashboard loads with sample data, branding defaults applied. If failed: Check auth provider user creation and tenant_id propagation.
Step 4: Build Per-Tenant Customization
Duration: 2-3 hours · Tool: Next.js / React
Build branding middleware that loads tenant config from DB with Redis caching (5-min TTL). Create dashboard layout engine that renders widgets from tenant-specific JSONB configuration. Build tenant settings page for branding, widget selection, and data source connections.
Verify: Change tenant branding — logo and colors update on next load. Add/remove widgets — layout persists. If failed: Check Redis cache invalidation on branding updates.
Step 5: Build Tenant Routing and Authentication
Duration: 2-3 hours · Tool: Next.js middleware
Implement subdomain routing (acme.dashboard.io) or path-based routing (/app/acme/) using Next.js middleware. Verify authenticated user belongs to the resolved tenant before granting access. Return 403 for cross-tenant access attempts.
Verify: Access two different tenant subdomains — each shows different branding and data. Cross-tenant access returns 403. If failed: Check DNS wildcard and SSL certificate configuration.
Step 6: Deploy and Monitor
Duration: 1-2 hours · Tool: Vercel or Cloudflare Pages
Configure wildcard DNS, set environment variables, create super-admin panel for tenant management, set up per-tenant monitoring (query latency, error rates, storage usage), and configure provisioning failure alerts.
Verify: Provision a new tenant via admin panel — full onboarding flow works end-to-end.
Output Schema
{
"output_type": "multi_tenant_dashboard_platform",
"format": "deployed SaaS application",
"components": [
{"name": "tenant_provisioning", "type": "api", "description": "REST endpoint for automated tenant creation", "required": true},
{"name": "rls_isolation", "type": "database", "description": "PostgreSQL RLS policies for 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": "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 and monitoring", "required": true}
],
"tenant_capacity": "1-200 tenants on shared schema",
"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 | RLS not enabled on table or missing policy | Audit all tables: SELECT tablename, rowsecurity FROM pg_tables; |
| Tenant provisioning timeout | Auth provider rate limit or DB pool exhaustion | Implement retry with backoff; increase connection pool |
| Subdomain not resolving | DNS wildcard not configured or SSL missing | Add *.domain CNAME record; request wildcard SSL |
| RLS blocks all rows (empty results) | Session variable not set by middleware | Add error handling: return 401 if no tenant context |
| Noisy neighbor degrading all tenants | One tenant running expensive aggregation | Add per-tenant SET statement_timeout; implement query cost limits |
| Branding not updating | Redis cache not invalidated | Invalidate cache key on branding update |
Cost Breakdown
| Component | Free (< 5 tenants) | Growth (5-50) | Scale (50-200) |
|---|---|---|---|
| Database (Supabase) | $0 (500MB) | $25/mo | $75/mo |
| 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 clause leaks all tenant data. Application bugs or raw SQL without the filter create immediate cross-tenant 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. [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. [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.
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 actual dashboard content, use domain-specific dashboard recipes.