Skip to content

Tenant Switching

Guide to implementing tenant switching in multi-tenant SaaS applications.

Overview

Tenant switching allows users (typically super admins) to switch between different tenant contexts in a single-tenant-per-session model, or allows users assigned to multiple tenants to switch between them.

Current Implementation

Lager Guru currently implements a single-tenant-per-user model:

  • Each user is assigned to exactly one tenant
  • User's tenant is determined at login
  • No UI switching needed (user always sees their tenant)

Future: Multi-Tenant User Support

For users assigned to multiple tenants, switching can be implemented:

1. Store Current Tenant in Session

typescript
// In AuthContext or TenantContext
const [currentTenantId, setCurrentTenantId] = useState<string | null>(null);

// Get user's tenants
const { data: tenantUsers } = await supabase
  .from('tenant_users')
  .select('tenant_id, role')
  .eq('user_id', user.id);

// Set initial tenant (first one, or last used)
useEffect(() => {
  if (tenantUsers && tenantUsers.length > 0) {
    const lastUsed = localStorage.getItem('last_tenant_id');
    const initialTenant = lastUsed && tenantUsers.find(tu => tu.tenant_id === lastUsed)
      ? lastUsed
      : tenantUsers[0].tenant_id;
    setCurrentTenantId(initialTenant);
  }
}, [tenantUsers]);

2. Tenant Selector Component

typescript
function TenantSelector() {
  const { user } = useAuth();
  const { currentTenantId, setCurrentTenantId } = useTenant();
  const [tenants, setTenants] = useState([]);
  
  useEffect(() => {
    // Fetch user's tenants
    const fetchTenants = async () => {
      const { data: tenantUsers } = await supabase
        .from('tenant_users')
        .select('tenant_id, role, tenant:tenants(id, name)')
        .eq('user_id', user.id);
      
      setTenants(tenantUsers || []);
    };
    fetchTenants();
  }, [user]);
  
  const handleSwitch = (tenantId: string) => {
    setCurrentTenantId(tenantId);
    localStorage.setItem('last_tenant_id', tenantId);
    // Optionally reload page to refresh all data
    window.location.reload();
  };
  
  return (
    <Select value={currentTenantId} onValueChange={handleSwitch}>
      {tenants.map(tu => (
        <SelectItem key={tu.tenant_id} value={tu.tenant_id}>
          {tu.tenant.name} ({tu.role})
        </SelectItem>
      ))}
    </Select>
  );
}

3. Route Guards

Protect routes to ensure tenant context is set:

typescript
function TenantRouteGuard({ children }: { children: React.ReactNode }) {
  const { currentTenantId } = useTenant();
  const { user } = useAuth();
  
  if (!currentTenantId) {
    // User has no tenant assignment
    return <Navigate to="/tenant-selection" />;
  }
  
  // Verify user has access to current tenant
  const { data: hasAccess } = await supabase
    .from('tenant_users')
    .select('id')
    .eq('user_id', user.id)
    .eq('tenant_id', currentTenantId)
    .maybeSingle();
  
  if (!hasAccess) {
    return <Navigate to="/tenant-selection" />;
  }
  
  return <>{children}</>;
}

Subdomain-Based Routing

Alternative approach: Use subdomains for tenant identification.

Implementation

typescript
// lib/subdomainRouting.ts
export function detectTenantFromSubdomain(): string | null {
  const hostname = window.location.hostname;
  const parts = hostname.split('.');
  
  if (parts.length >= 3) {
    const subdomain = parts[0];
    // Map subdomain to tenant_id
    return getTenantIdFromSubdomain(subdomain);
  }
  
  return null;
}

DNS Configuration

acme.lager-guru.com    → Tenant: Acme Corp
example.lager-guru.com → Tenant: Example Inc

Route Handling

typescript
// App.tsx
const tenantId = detectTenantFromSubdomain();

if (tenantId) {
  // Set tenant context
  setCurrentTenantId(tenantId);
} else {
  // No subdomain, show tenant selection
  return <TenantSelectionPage />;
}

Tenant Context Provider

Create a context to manage tenant state:

typescript
// contexts/TenantContext.tsx
interface TenantContextType {
  currentTenantId: string | null;
  setCurrentTenantId: (id: string | null) => void;
  currentTenant: Tenant | null;
  userTenants: TenantUser[];
  isTenantAdmin: boolean;
}

export const TenantProvider = ({ children }) => {
  const { user } = useAuth();
  const [currentTenantId, setCurrentTenantId] = useState<string | null>(null);
  const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
  const [userTenants, setUserTenants] = useState<TenantUser[]>([]);
  
  // Load user's tenants
  useEffect(() => {
    if (user) {
      loadUserTenants();
    }
  }, [user]);
  
  // Load current tenant details
  useEffect(() => {
    if (currentTenantId) {
      loadTenantDetails(currentTenantId);
    }
  }, [currentTenantId]);
  
  const isTenantAdmin = userTenants.find(
    tu => tu.tenant_id === currentTenantId && tu.role === 'tenant_admin'
  ) !== undefined;
  
  return (
    <TenantContext.Provider value={{
      currentTenantId,
      setCurrentTenantId,
      currentTenant,
      userTenants,
      isTenantAdmin,
    }}>
      {children}
    </TenantContext.Provider>
  );
};

Automatic Tenant Injection

All queries automatically include tenant filtering via RLS, but you can also explicitly filter:

typescript
// lib/tenants.ts
export async function getTenantScopedQuery<T>(
  table: string,
  query: (client: SupabaseClient) => PostgrestQueryBuilder<T>
) {
  const tenantId = await getCurrentUserTenantId();
  
  if (!tenantId) {
    throw new Error('No tenant context');
  }
  
  return query(supabase).eq('tenant_id', tenantId);
}

// Usage
const { data } = await getTenantScopedQuery('shipments', (client) =>
  client.from('shipments').select('*')
);

Security Considerations

1. Validate Tenant Access

Always verify user has access to tenant before switching:

typescript
async function switchTenant(tenantId: string) {
  const { user } = useAuth();
  
  // Verify access
  const { data } = await supabase
    .from('tenant_users')
    .select('id')
    .eq('user_id', user.id)
    .eq('tenant_id', tenantId)
    .maybeSingle();
  
  if (!data) {
    throw new Error('Access denied to this tenant');
  }
  
  // Switch tenant
  setCurrentTenantId(tenantId);
}

2. Clear Sensitive Data

When switching tenants, clear any cached data:

typescript
function switchTenant(tenantId: string) {
  // Clear caches
  queryClient.clear();
  localStorage.removeItem('cached_data');
  
  // Switch tenant
  setCurrentTenantId(tenantId);
  
  // Reload page to ensure clean state
  window.location.reload();
}

3. Audit Logging

Log tenant switches for security:

typescript
async function switchTenant(tenantId: string) {
  // Log switch
  await supabase.from('audit_logs').insert({
    user_id: user.id,
    action: 'tenant_switch',
    details: { from_tenant: currentTenantId, to_tenant: tenantId },
    tenant_id: tenantId,
  });
  
  // Perform switch
  setCurrentTenantId(tenantId);
}

UI Implementation

Tenant Switcher in Header

typescript
// components/layout/Header.tsx
function TenantSwitcher() {
  const { currentTenantId, userTenants, setCurrentTenantId } = useTenant();
  
  if (userTenants.length <= 1) {
    // No switching needed
    return null;
  }
  
  return (
    <DropdownMenu>
      <DropdownMenuTrigger>
        {currentTenant?.name || 'Select Tenant'}
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        {userTenants.map(tu => (
          <DropdownMenuItem
            key={tu.tenant_id}
            onClick={() => switchTenant(tu.tenant_id)}
          >
            {tu.tenant.name}
            {tu.role === 'tenant_admin' && ' (Admin)'}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Best Practices

  1. Single Tenant Per Session: For most use cases, one tenant per user session is sufficient
  2. Explicit Switching: If multi-tenant users are needed, make switching explicit and logged
  3. Clear State: Always clear caches when switching tenants
  4. RLS Enforcement: RLS policies automatically enforce tenant isolation
  5. UI Indicators: Clearly show which tenant the user is currently viewing

Released under Commercial License