Back to Home
Architecture

Building Multi-Tenant SaaS — A Field Guide

Practical architectural patterns for production multi-tenant SaaS — database design, RBAC, JWT custom claims, tenant-scoped queries, and the operational issues that show up at scale. Drawn from real engagements building HR and fleet platforms for multi-brand operators.

12 min read

The Challenge

I've worked across multiple multi-tenant SaaS engagements — including a 6-month tenure on a multi-brand QSR HR platform deployed across KFC, Five Guys, and Burger King franchise networks. The core challenge is universal: build one codebase that serves multiple distinct customer organisations, keep their data completely isolated, and scale to thousands of locations without operations costs ballooning. This piece is a field guide to the patterns that survive contact with production.

Multi-Tenancy: Database Design Decisions

One of the first critical decisions was choosing the multi-tenancy strategy. There are three main approaches:

  • Database per tenant - Complete isolation but expensive and hard to maintain
  • Schema per tenant - Good balance but still management overhead
  • Shared database with tenant ID - Most scalable, requires careful design

For EasyRHIS, I chose the shared database with tenant ID approach using PostgreSQL. Every table includes a tenant_id column, and I implemented Row-Level Security (RLS) policies to enforce data isolation at the database level.

Implementation Strategy

The key was making tenant isolation invisible to application code. I created a custom middleware that:

  1. Extracts tenant information from JWT tokens
  2. Sets session context variables in PostgreSQL
  3. Applies RLS policies automatically on every query

This meant developers could write queries without worrying about tenant filtering — the database handles it automatically. A query like SELECT * FROM employeesonly returns employees for the authenticated tenant.

Authentication & Authorization Architecture

Multi-tenant authentication adds complexity. I needed to support:

  • Restaurant chain administrators (full access to their locations)
  • Store managers (access to specific locations)
  • Employees (limited self-service access)

I implemented a hierarchical role-based access control (RBAC) system using Supabase Auth with custom claims in JWT tokens:

{
  "tenant_id": "uuid-here",
  "role": "store_manager",
  "locations": ["location-1", "location-2"],
  "permissions": ["read:employees", "write:schedules"]
}

Scaling Considerations

As the platform grew to serve multiple restaurant chains with hundreds of locations, several challenges emerged:

1. Query Performance

Adding tenant_id to every query can hurt performance if not properly indexed. I created composite indexes with tenant_id as the first column:

CREATE INDEX idx_employees_tenant_location 
ON employees(tenant_id, location_id, created_at DESC);

2. Database Connection Pooling

With multiple tenants hitting the database simultaneously, connection pooling became critical. I used Supabase's built-in connection pooling with PgBouncer in transaction mode to handle thousands of concurrent requests.

3. Background Jobs

Scheduled tasks like payroll processing and report generation needed to run per tenant. I implemented a job queue system that:

  • Processes jobs tenant-by-tenant to maintain isolation
  • Implements rate limiting per tenant to prevent resource monopolization
  • Uses separate queues for high-priority vs. batch operations

Frontend Multi-Tenancy

The frontend (Next.js 14 + TypeScript) needed to handle tenant switching gracefully. I implemented:

Tenant Context Provider

// src/contexts/TenantContext.tsx
const TenantContext = createContext<TenantContextType>(null!);

export function TenantProvider({ children }: { children: ReactNode }) {
  const [tenant, setTenant] = useState<Tenant | null>(null);
  
  useEffect(() => {
    // Load tenant from JWT on mount
    const loadTenant = async () => {
      const session = await supabase.auth.getSession();
      if (session?.data?.session) {
        setTenant(session.data.session.user.user_metadata.tenant);
      }
    };
    loadTenant();
  }, []);

  return (
    <TenantContext.Provider value={{ tenant }}>
      {children}
    </TenantContext.Provider>
  );
}

Tenant-Specific Branding

Each restaurant chain wanted their own branding. I implemented a theming system that:

  • Loads tenant-specific colors, logos, and fonts from the database
  • Applies CSS variables dynamically on tenant switch
  • Caches tenant configuration in localStorage for performance

Security Best Practices

Multi-tenancy security is paramount. Here's what I learned:

1. Never Trust Client-Side Tenant IDs

Always extract tenant information from server-side JWT tokens, never from client requests. Malicious users could forge tenant IDs to access other tenants' data.

2. Audit Logging

I implemented comprehensive audit logs that track:

  • Who accessed what data
  • When they accessed it
  • What changes were made
  • Their tenant context at the time

3. Rate Limiting Per Tenant

To prevent one tenant from affecting others, I implemented tenant-aware rate limiting using Redis:

// Example rate limit: 100 requests per minute per tenant
const key = `rate_limit:${tenantId}:${Math.floor(Date.now() / 60000)}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 60);
if (count > 100) throw new Error('Rate limit exceeded');

Deployment & Infrastructure

EasyRHIS is deployed on Vercel with Supabase as the backend. Key infrastructure decisions:

  • Edge Functions for tenant-specific API endpoints that run close to users
  • Database Replicas for read-heavy operations like reporting
  • CDN Caching with tenant-aware cache keys for static assets

Lessons Learned

1. Start with Multi-Tenancy from Day One

Retrofitting multi-tenancy into an existing application is extremely difficult. Build it into the architecture from the beginning, even if you only have one tenant initially.

2. Test Tenant Isolation Rigorously

Write integration tests that verify data isolation. I created test suites that:

  • Authenticate as different tenants
  • Attempt to access other tenants' data
  • Verify that operations only affect the current tenant

3. Monitor Per-Tenant Metrics

Build observability into your multi-tenant system. Track metrics like:

  • Request volume per tenant
  • Database query performance per tenant
  • Error rates per tenant
  • Storage usage per tenant

This helps identify problematic tenants and optimize accordingly.

4. Plan for Tenant Migration

Sometimes tenants need to be moved (different database, upgraded tier, etc.). I built migration tools early that can:

  • Export all tenant data with referential integrity
  • Import into a new environment
  • Validate data consistency post-migration

Conclusion

Building EasyRHIS taught me that multi-tenant SaaS architecture requires careful planning across every layer — database, backend, frontend, and infrastructure. The key is making tenant isolation automatic and invisible to application code, while maintaining security and performance at scale.

If you're building a multi-tenant SaaS platform, start with these principles:

  • Design for multi-tenancy from day one
  • Enforce tenant isolation at the database level
  • Never trust client-side tenant context
  • Monitor and rate-limit per tenant
  • Build tenant migration tools early

The result is a scalable, secure platform that can serve hundreds of restaurant locations while keeping each client's data completely isolated.

Want to discuss multi-tenant architecture for your SaaS project?

I'd be happy to share more insights from building production-scale multi-tenant systems.

Get in touch →