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]
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 |
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+.
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.
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.
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.
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.
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_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 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 | 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 |
| 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 |
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]
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]
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]
Store all per-tenant differences as config (branding JSON, widget layouts, feature flags). One codebase, one deployment, N configurations.
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.