Skip to main content

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:

BadGood
/api/users/api/tenants/{tenantId}/users
queryKey: ['users']queryKey: ['users', tenantId]
SELECT * FROM usersSELECT * FROM users WHERE tenant_id = ?

This ensures data is scoped per tenant at every layer.

Key Takeaways

  • Use a TenantContext to track the current tenant globally and switch seamlessly.
  • Include tenantId in all query keys so switching tenants refetches the correct data.
  • Set enabled: !!currentTenant on queries to prevent requests before a tenant is selected.
  • Invitations are asynchronous; use a backend service to email the invite link.
  • Always verify tenantId on 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.

Further Reading