Design Multi-tenant Admin Panel
Frontend system design for multi-tenant admin — tenancy switching, RBAC UI, isolation, theming, and safe data fetching.
- system-design
- interview
- architecture
- admin
- multi-tenant
Scope the problem
In scope:
- Tenant context (org/workspace) selection
- RBAC-driven navigation and actions
- Admin CRUD tables and detail views
- Theming/branding per tenant (light)
- Preventing cross-tenant data leaks in the client
- Audit-friendly UX (who did what — display)
Out of scope: billing systems, full IAM backend design.
Assumptions: B2B SaaS; user may belong to multiple tenants; high-trust admin actions.
Tenancy models (frontend impact)
| Model | Client implication |
|---|---|
Path /t/:tenantId/... |
explicit; good cache keys; shareable links |
Subdomain acme.app.com |
brand isolation; cookie domain care |
| Header/context only | easy to mess up; leaks if forgotten |
Prefer path or subdomain so tenant is visible in every request mental model.
Architecture
┌──────────────────────────────────────────────────────────┐
│ Shell: tenant switcher, user menu, global search │
├──────────────┬───────────────────────────────────────────┤
│ Nav (RBAC) │ Main: routes under tenant scope │
├──────────────┴───────────────────────────────────────────┤
│ API client: always attaches tenant id; 403 handling │
└──────────────────────────────────────────────────────────┘
Tenant context
type Tenant = {
id: string;
name: string;
role: "owner" | "admin" | "member" | "viewer";
brand?: { primary: string; logoUrl: string };
};
type Session = {
user: { id: string; email: string };
tenants: Tenant[];
currentTenantId: string;
};
Bootstrap: GET /me → tenants + permissions. Switching tenant:
- Update context + URL
- Clear react-query cache (or use tenant-scoped keys exclusively)
- Abort in-flight requests
- Reload shell nav for permissions
queryKey: [tenantId, "users", filters]
// never ["users", filters] alone
RBAC in the UI
- Server is source of truth; UI hides what you can’t do for UX — still enforce server-side
- Central
can(permission)helper - Nav config filtered by permissions
- Buttons disabled/hidden with consistent patterns; don’t leave dead clickable controls
type Permission =
| "users:read"
| "users:write"
| "billing:read"
| "settings:write";
function can(session: Session, perm: Permission) {
return session.permissionSet.has(perm);
}
Data isolation footguns
- Global singleton store retaining previous tenant’s rows
localStoragekeys without tenant prefix- Service worker caches mixing tenants
- WebSocket channel not re-subscribed on switch
Mitigations: tenant id in every cache key; on switch hard-reset stores; SW cache partitions; close sockets.
Admin UI patterns
- List + filters + bulk actions with permission gates
- Destructive actions confirm modals + type-name-to-confirm for delete tenant
- Audit log page (read-only)
- Impersonation (if exists): huge banner “Viewing as X”; separate session mode
Theming
Apply tenant brand tokens on data-tenant / CSS variables at shell:
document.documentElement.style.setProperty("--brand", tenant.brand.primary);
Keep components brand-agnostic.
Performance
- Heavy admin charts: lazy routes
- Tables: server pagination + sort (sortable table)
- Prefetch unlikely — admins tolerate slightly slower; correctness first
- Code-split rarely used super-admin tools
Security UX
- Short session with re-auth for sensitive (SSO step-up)
- CSRF/session as in Auth Session Design
- Don’t echo secrets in client logs
- 403 page vs hide nav — avoid tenant enumeration if required
Tradeoffs
- Path tenant vs subdomain — SEO irrelevant; cookies and TLS certs differ
- Hard cache clear vs scoped keys only — safety vs switch speed
- Feature flags per tenant — complexity
- Micro-frontends for admin modules — org scale vs overhead
Interview close
Make tenant explicit in URL and query keys; RBAC as UI filter not security boundary; hard isolation on switch; confirm destructive flows; brand via tokens. Emphasize leak footguns — that’s the senior signal.
Navigation config example
const nav = [
{ href: "users", label: "Users", perm: "users:read" },
{ href: "billing", label: "Billing", perm: "billing:read" },
{ href: "audit", label: "Audit log", perm: "audit:read" },
].filter((i) => can(session, i.perm));
Generate breadcrumbs from the same config to avoid drift.
Cross-tenant super-admin
Platform operators may need a break-glass tenant list. Put that in a separate route tree with stronger re-auth and a persistent red banner. Never reuse normal tenant switcher for impersonation without audit events.
Data grid defaults
Server-driven pagination, sort, and export CSV as async job for large sets. Frontend should not download 100k rows to “export.”
Related on this site
- Auth Session Design for SPAs
- Feature Flags Client Architecture
- State Management at Scale
- System Design Interview Framework