Dashboard Settings & Multi-Tenant Features
A multi-tenant SaaS dashboard serves multiple customers (tenants), each with isolated data, team members, and settings. Stripe, Notion, and Slack all use this model: your account is a "workspace" separate from others. Implementing it requires careful data scoping and UI context management so Team A never sees Team B's data.
I shipped a multi-tenant CRM that initially shared a single tenant context globally, causing subtle bugs where users could switch teams and see overlapping data. Switching to a tenant-scoped query key solved it in one afternoon. This article teaches you the patterns and gotchas.
What You'll Learn
You'll build:
- A workspace/tenant selector in the sidebar
- User invitation flow with email verification
- Role and permission management
- Tenant-specific API queries scoped by
tenantId - Settings pages for billing, members, and integrations
- Data isolation: Team A's data never leaks to Team B
Prerequisites
You need the authentication and state-management setup from earlier articles. Understanding of the tenantId context pattern is helpful. You'll use React Router and TanStack Query from previous articles.
Step 1: Create a Tenant Context
Create src/context/TenantContext.tsx:
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export interface Tenant {
id: string;
name: string;
slug: string;
planId: string;
createdAt: string;
}
interface TenantContextType {
currentTenant: Tenant | null;
allTenants: Tenant[];
switchTenant: (tenantId: string) => void;
isLoadingTenants: boolean;
}
const TenantContext = createContext<TenantContextType | undefined>(undefined);
export function TenantProvider({ children }: { children: ReactNode }) {
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [allTenants, setAllTenants] = useState<Tenant[]>([]);
const [isLoadingTenants, setIsLoadingTenants] = useState(true);
// Load tenants on mount
React.useEffect(() => {
async function loadTenants() {
try {
const response = await fetch('/api/tenants');
if (response.ok) {
const tenants: Tenant[] = await response.json();
setAllTenants(tenants);
// Set the first tenant as current (or use localStorage to persist choice)
const saved = localStorage.getItem('current_tenant_id');
const initial = tenants.find((t) => t.id === saved) || tenants[0];
setCurrentTenant(initial || null);
}
} catch (error) {
console.error('Failed to load tenants:', error);
} finally {
setIsLoadingTenants(false);
}
}
loadTenants();
}, []);
const switchTenant = useCallback((tenantId: string) => {
const tenant = allTenants.find((t) => t.id === tenantId);
if (tenant) {
setCurrentTenant(tenant);
localStorage.setItem('current_tenant_id', tenantId);
}
}, [allTenants]);
return (
<TenantContext.Provider value={{ currentTenant, allTenants, switchTenant, isLoadingTenants }}>
{children}
</TenantContext.Provider>
);
}
export function useTenant() {
const context = useContext(TenantContext);
if (context === undefined) throw new Error('useTenant must be used within TenantProvider');
return context;
}
Wrap your app with TenantProvider at the root level, after AuthProvider and QueryClientProvider.
Step 2: Scope API Queries by Tenant
Modify your hooks to include tenantId in the query key. Create src/hooks/useTenantUsers.ts:
import { useQuery } from '@tanstack/react-query';
import { useTenant } from '../context/TenantContext';
export interface TenantUser {
id: string;
email: string;
name: string;
role: 'owner' | 'admin' | 'member';
inviteStatus: 'accepted' | 'pending';
}
export function useTenantUsers() {
const { currentTenant } = useTenant();
return useQuery<TenantUser[]>({
// Include tenantId in the query key so switching tenants refetches
queryKey: ['tenant-users', currentTenant?.id],
queryFn: async () => {
if (!currentTenant) return [];
const response = await fetch(`/api/tenants/${currentTenant.id}/users`);
if (!response.ok) throw new Error('Failed to fetch tenant users');
return response.json();
},
enabled: !!currentTenant, // Don't fetch until tenant is selected
});
}
The queryKey: ['tenant-users', currentTenant?.id] ensures that switching tenants automatically refetches the correct data. The enabled flag prevents queries when no tenant is selected.
Step 3: Build a Workspace Selector
Create src/components/TenantSelector.tsx:
import { useTenant } from '../context/TenantContext';
export function TenantSelector() {
const { currentTenant, allTenants, switchTenant } = useTenant();
return (
<div className="px-4 py-3 border-b border-slate-700">
<label className="text-xs text-slate-400">Current Workspace</label>
<select
value={currentTenant?.id || ''}
onChange={(e) => switchTenant(e.target.value)}
className="w-full mt-2 px-3 py-2 bg-slate-800 text-white rounded border border-slate-600"
>
{allTenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name}
</option>
))}
</select>
</div>
);
}
Add this to your sidebar. Switching tenants updates currentTenant, which re-triggers all scoped queries.
Step 4: Build a User Invitation System
Create src/components/InviteUserModal.tsx:
import { useState } from 'react';
import { useTenant } from '../context/TenantContext';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface InviteUserModalProps {
onClose: () => void;
}
export function InviteUserModal({ onClose }: InviteUserModalProps) {
const { currentTenant } = useTenant();
const [email, setEmail] = useState('');
const [role, setRole] = useState<'admin' | 'member'>('member');
const queryClient = useQueryClient();
const { mutate: inviteUser, isPending, error } = useMutation({
mutationFn: async () => {
if (!currentTenant) throw new Error('No tenant selected');
const response = await fetch(`/api/tenants/${currentTenant.id}/invitations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to send invitation');
}
return response.json();
},
onSuccess: () => {
// Refetch tenant users to include the new invitation
queryClient.invalidateQueries({ queryKey: ['tenant-users', currentTenant?.id] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
inviteUser();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Invite Team Member</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as 'admin' | 'member')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="member">Member (view-only)</option>
<option value="admin">Admin (full access)</option>
</select>
</div>
{error && <p className="text-red-600 text-sm">{(error as Error).message}</p>}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isPending ? 'Sending...' : 'Send Invitation'}
</button>
</div>
</form>
</div>
</div>
);
}
When the user submits, the invitation is sent to the backend, which verifies the tenant and email, then invalidates the tenant-users query to refetch with the new pending member.
Step 5: Build a Settings Page
Create src/pages/TenantSettings.tsx:
import { useState } from 'react';
import { useTenant } from '../context/TenantContext';
import { useTenantUsers } from '../hooks/useTenantUsers';
import { InviteUserModal } from '../components/InviteUserModal';
export default function TenantSettings() {
const { currentTenant } = useTenant();
const { data: users, isLoading } = useTenantUsers();
const [inviteModalOpen, setInviteModalOpen] = useState(false);
if (!currentTenant) return <p>No tenant selected</p>;
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-gray-600">Manage your workspace, members, and billing.</p>
</div>
{/* Workspace Info */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Workspace</h2>
<div className="space-y-2">
<p>
<strong>Name:</strong> {currentTenant.name}
</p>
<p>
<strong>ID:</strong> {currentTenant.id}
</p>
<p>
<strong>Plan:</strong> {currentTenant.planId}
</p>
</div>
</section>
{/* Team Members */}
<section className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Team Members</h2>
<button
onClick={() => setInviteModalOpen(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Invite Member
</button>
</div>
{isLoading ? (
<p>Loading members...</p>
) : (
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">Name</th>
<th className="text-left py-2">Email</th>
<th className="text-left py-2">Role</th>
<th className="text-left py-2">Status</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id} className="border-b hover:bg-gray-50">
<td className="py-3">{user.name}</td>
<td className="py-3">{user.email}</td>
<td className="py-3 capitalize">{user.role}</td>
<td className="py-3">
<span
className={`px-2 py-1 rounded text-sm ${
user.inviteStatus === 'accepted'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{user.inviteStatus}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{inviteModalOpen && <InviteUserModal onClose={() => setInviteModalOpen(false)} />}
</div>
);
}
This page displays workspace info and team members, with a button to invite new users.
Key Takeaway on Data Isolation
Always include tenantId in API routes and query keys:
| Bad | Good |
|---|---|
/api/users | /api/tenants/{tenantId}/users |
queryKey: ['users'] | queryKey: ['users', tenantId] |
SELECT * FROM users | SELECT * FROM users WHERE tenant_id = ? |
This ensures data is scoped per tenant at every layer.
Key Takeaways
- Use a
TenantContextto track the current tenant globally and switch seamlessly. - Include
tenantIdin all query keys so switching tenants refetches the correct data. - Set
enabled: !!currentTenanton queries to prevent requests before a tenant is selected. - Invitations are asynchronous; use a backend service to email the invite link.
- Always verify
tenantIdon the backend before returning any data—never trust the client.
Frequently Asked Questions
What if a user tries to access another tenant's data by changing the URL?
The backend should verify that the user has access to that tenant (via a JOIN with a team_members or user_tenants table). If not, return a 403 Forbidden. Never rely on the client to prevent access.
How do I handle users in multiple tenants?
Store a separate user_tenants join table that maps users to tenants with their role. When a user logs in, fetch all tenants they belong to, and let them switch between them (as shown in the TenantSelector).
Should I include tenantId in the localStorage?
Yes, save it so users return to their last-used workspace. Clear it on logout to prevent auto-login to a sensitive workspace from a shared machine.
Can I use a single Redux store for multi-tenant data?
Possible but risky. If you do, normalize the store by tenant: state.tenants[tenantId].users. It's easier to use React Query and keep state scoped by query key.
How do I audit changes per tenant?
Log mutations with the tenantId: when a user invites someone, record { action: 'invite', tenantId, userId, invitedEmail, timestamp }. This is legally required for many SaaS applications.