Multi-tenancy is a software architecture pattern where a single instance of an application serves multiple customers (tenants). Each tenant’s data is isolated and remains invisible to other tenants, while sharing the same infrastructure and codebase.
What is Multi-tenancy?
Multi-tenancy, especially in cloud-based systems, refers to the ability of a software application to serve multiple customers simultaneously. While these customers share the same infrastructure and codebase, their data remains separate, and each customer has exclusive access to their own data.
Benefits of Multi-tenancy:
- Resource Sharing: Efficient use of shared infrastructure reduces costs
- Cost Savings: Lower maintenance costs passed on to customers
- Customization: Each tenant can adjust settings to their needs
- Easy Updates: System-wide updates benefit all tenants at once
Use Cases:
- Cloud Office Tools: Multiple organizations share document management and collaboration features
- CRM Systems: Businesses manage customer interactions on a shared platform with secure, custom configurations
- ERP Systems: Companies use shared ERP solutions with separate data and configurations
- E-commerce Platforms: Sellers run personalized storefronts on a shared backend
- LMS Platforms: Schools and organizations deliver courses on a shared learning system
Implementation Approaches
There are several approaches to implementing multi-tenancy in Refine applications:
1. Route-based Multi-tenancy
In this approach, the tenant identifier is extracted from the URL route:
// Example: /tenants/:tenantId/posts
import { RefineEnterprise } from "@refinedev/enterprise";
import { useRouterAdapter, WithTenant } from "@refinedev/multitenancy";
const App = () => {
return (
<RefineEnterprise
multitenancyProvider={{
adapter: useRouterAdapter({
parameterName: "tenantId",
parameterKey: "id",
}),
fetchTenants: async () => {
const response = await dataProvider(API_URL).getList({
resource: "tenants",
pagination: { mode: "off" },
});
return {
tenants: response.data,
defaultTenant: response.data[0],
};
},
}}
>
<WithTenant
fallback={<div>Tenant not found</div>}
loadingComponent={<div>Loading...</div>}
>
{/* Your app code */}
</WithTenant>
</RefineEnterprise>
);
};
2. Subdomain-based Multi-tenancy
Extract tenant information from the subdomain:
import { useCustomAdapter } from "@refinedev/multitenancy";
const subdomainAdapter = () => {
const getTenantId = () => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
return parts.length > 2 ? parts[0] : null;
};
return {
get: getTenantId,
set: (tenantId: string) => {
window.location.hostname = `${tenantId}.${window.location.hostname}`;
},
};
};
const App = () => {
return (
<RefineEnterprise
multitenancyProvider={{
adapter: subdomainAdapter(),
fetchTenants: async () => {
// Fetch tenants logic
},
}}
>
{/* ... */}
</RefineEnterprise>
);
};
Pass tenant information through HTTP headers:
const customDataProvider = {
...baseDataProvider,
getList: async ({ resource, filters = [], meta, ...props }) => {
const { tenantId } = meta;
// Add tenant header to requests
const headers = {
'X-Tenant-ID': tenantId,
};
return baseDataProvider.getList({
resource,
filters,
meta: { ...meta, headers },
...props,
});
},
};
Data Provider Integration
Refine automatically passes the tenantId to your data provider in the meta object:
import dataProvider from "@refinedev/simple-rest";
const API_URL = "https://api.example.com";
const baseDataProvider = dataProvider(API_URL);
const multiTenantDataProvider = {
...baseDataProvider,
getList: async ({ resource, filters = [], meta, ...props }) => {
const { tenantId } = meta;
// Option 1: Add tenantId as a filter
if (tenantId) {
filters.push({
field: "tenantId",
operator: "eq",
value: tenantId,
});
}
// Option 2: Add tenantId to the URL
// const url = `${API_URL}/tenants/${tenantId}/${resource}`;
return baseDataProvider.getList({
resource,
filters,
meta,
...props,
});
},
create: async ({ resource, variables, meta }) => {
const { tenantId } = meta;
// Ensure tenantId is included in the payload
return baseDataProvider.create({
resource,
variables: {
...variables,
tenantId,
},
meta,
});
},
// Implement other methods similarly...
};
Tenant Selector UI
Add a tenant selector to allow users to switch between tenants:
import { TenantSelect } from "@refinedev/multitenancy/antd";
// In your Header component
export const Header = () => {
return (
<div>
<TenantSelect
optionLabel="name"
optionValue="id"
onChange={(tenant) => console.log('Switched to tenant:', tenant)}
/>
</div>
);
};
For Material UI:
import { TenantSelect } from "@refinedev/multitenancy/mui";
<TenantSelect
optionLabel="name"
optionValue="id"
sortTenants={(a, b) => a.name.localeCompare(b.name)}
/>
Access Control with Multi-tenancy
Combine multi-tenancy with access control:
export const accessControlProvider = {
can: async ({ resource, action, params }) => {
const { tenantId } = params?.meta || {};
// Check if user has access to this tenant
const userTenants = await getUserTenants();
if (!userTenants.includes(tenantId)) {
return {
can: false,
reason: "You don't have access to this tenant",
};
}
// Check resource-level permissions
if (resource === "posts" && action === "delete") {
const userRole = await getUserRole(tenantId);
return {
can: userRole === "admin",
reason: userRole !== "admin" ? "Only admins can delete posts" : undefined,
};
}
return { can: true };
},
};
Always validate tenant access on the server side. Client-side tenant filtering is not sufficient for security.
Database Isolation Patterns
1. Shared Database, Shared Schema
All tenants share the same database and tables, with a tenantId column:
CREATE TABLE posts (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
title VARCHAR(255),
content TEXT,
created_at TIMESTAMP
);
CREATE INDEX idx_posts_tenant_id ON posts(tenant_id);
Pros: Simple, cost-effective
Cons: Risk of data leakage if queries are not properly filtered
2. Shared Database, Separate Schemas
Each tenant has their own schema within the same database:
CREATE SCHEMA tenant_123;
CREATE SCHEMA tenant_456;
CREATE TABLE tenant_123.posts (...);
CREATE TABLE tenant_456.posts (...);
Pros: Better isolation, easier to backup individual tenants
Cons: More complex to manage, schema migration complexity
3. Separate Databases
Each tenant has a completely separate database:
const getTenantDataProvider = (tenantId: string) => {
const tenantDbUrl = `https://api-${tenantId}.example.com`;
return dataProvider(tenantDbUrl);
};
Pros: Maximum isolation and security
Cons: Higher cost, complex management
Working with useMultitenancy Hook
import { useMultitenancy } from "@refinedev/multitenancy";
const MyComponent = () => {
const {
tenant, // Current tenant object
tenants, // List of all tenants
isLoading, // Loading state
setTenant, // Function to set current tenant
fetchTenants, // Function to refetch tenants
deleteTenant, // Function to delete current tenant
} = useMultitenancy();
return (
<div>
<h1>Current Tenant: {tenant?.name}</h1>
<button onClick={() => setTenant(tenants[0])}>
Switch to {tenants[0]?.name}
</button>
</div>
);
};
Best Practices
-
Always validate tenant access server-side: Never rely solely on client-side checks
-
Use database indexes: Add indexes on
tenantId columns for better query performance
-
Implement tenant context globally: Ensure all database queries include tenant filtering
-
Handle tenant switching gracefully: Clear caches and refresh data when switching tenants
-
Monitor tenant usage: Track resource usage per tenant for billing and capacity planning
-
Test cross-tenant isolation: Regularly audit to ensure no data leakage between tenants
-
Document tenant onboarding: Create clear processes for adding and removing tenants
Example: Complete Multi-tenant App
Here’s a complete example with Strapi backend:
import { Refine } from "@refinedev/core";
import { RefineEnterprise } from "@refinedev/enterprise";
import { useRouterAdapter, WithTenant } from "@refinedev/multitenancy";
import dataProvider from "@refinedev/strapi-v4";
import routerProvider from "@refinedev/react-router";
const API_URL = "https://api.example.com";
const App = () => {
return (
<RefineEnterprise
routerProvider={routerProvider}
dataProvider={dataProvider(API_URL)}
multitenancyProvider={{
adapter: useRouterAdapter(),
fetchTenants: async () => {
const response = await dataProvider(API_URL).getList({
resource: "organizations",
pagination: { mode: "off" },
});
return {
tenants: response.data,
defaultTenant: response.data[0],
};
},
}}
resources={[
{
name: "posts",
list: "/:tenantId/posts",
create: "/:tenantId/posts/create",
edit: "/:tenantId/posts/edit/:id",
show: "/:tenantId/posts/show/:id",
},
]}
>
<WithTenant
fallback={<div>Tenant not found</div>}
loadingComponent={<div>Loading tenant...</div>}
>
{/* Your app routes */}
</WithTenant>
</RefineEnterprise>
);
};
Further Reading