ESC

Type to search the knowledge base.

Design Multi-tenant Admin Panel

Frontend system design for multi-tenant admin — tenancy switching, RBAC UI, isolation, theming, and safe data fetching.

advanced4 min read
  • 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:

  1. Update context + URL
  2. Clear react-query cache (or use tenant-scoped keys exclusively)
  3. Abort in-flight requests
  4. 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

  1. Global singleton store retaining previous tenant’s rows
  2. localStorage keys without tenant prefix
  3. Service worker caches mixing tenants
  4. 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

  1. Path tenant vs subdomain — SEO irrelevant; cookies and TLS certs differ
  2. Hard cache clear vs scoped keys only — safety vs switch speed
  3. Feature flags per tenant — complexity
  4. 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.

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.”

Further reading