Skip to main content
Architecture
13 min read
January 20, 2026

Multi-Tenant SaaS Frontend Architecture: A Practical Guide

How to build a frontend that serves hundreds of organizations without becoming unmaintainable

Segev Sinay

Segev Sinay

Frontend Architect

Share:

Most "multi-tenant SaaS" tutorials show you how to add an organizationId to your database queries and call it a day. If only it were that simple.

I recently built a multi-tenant social media management platform from scratch — 575+ API endpoints, multi-organization support, role-based access, tenant-specific settings, and data isolation across every layer. The frontend architecture decisions I made in the first week determined whether the platform would scale to hundreds of tenants or collapse under its own complexity.

Here's what I learned.

What Multi-Tenancy Actually Means for the Frontend

Multi-tenancy isn't just a backend concern. Your frontend needs to:

  1. Scope every data request to the current tenant
  2. Enforce permissions before rendering actions, not after
  3. Support tenant switching without page reloads
  4. Isolate tenant data so Organization A never sees Organization B's data
  5. Handle tenant-specific configuration (features, branding, limits)

Miss any of these, and you'll either leak data between tenants (a critical security issue) or build a system so rigid that adding new tenant features requires refactoring everything.

The Tenant Context Pattern

Every multi-tenant frontend needs a central tenant context that every component and API call can reference.

interface Tenant {
  id: string;
  name: string;
  slug: string;
  plan: "starter" | "professional" | "enterprise";
  features: string[];
  branding?: {
    primaryColor: string;
    logo: string;
  };
  limits: {
    maxUsers: number;
    maxProjects: number;
    maxStorageMB: number;
  };
}

interface TenantContextValue {
  tenant: Tenant | null;
  isLoading: boolean;
  switchTenant: (tenantId: string) => Promise<void>;
  hasFeature: (feature: string) => boolean;
}

const TenantContext = createContext<TenantContextValue | undefined>(undefined);

export function TenantProvider({ children }: { children: React.ReactNode }) {
  const [tenant, setTenant] = useState<Tenant | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const switchTenant = useCallback(async (tenantId: string) => {
    setIsLoading(true);
    const data = await api.getTenant(tenantId);
    setTenant(data);
    localStorage.setItem("activeTenantId", tenantId);
    // Invalidate all tenant-scoped queries
    queryClient.invalidateQueries();
    setIsLoading(false);
  }, []);

  const hasFeature = useCallback(
    (feature: string) => tenant?.features.includes(feature) ?? false,
    [tenant]
  );

  return (
    <TenantContext.Provider value={{ tenant, isLoading, switchTenant, hasFeature }}>
      {children}
    </TenantContext.Provider>
  );
}

Every API call includes the tenant ID. Every component that shows tenant-specific data consumes this context. Every permission check starts here.

Role-Based Access Control (RBAC)

RBAC in a multi-tenant system isn't just "admin vs user." It's a hierarchy:

Organization Owner
  └── Organization Admin
        └── Project Manager
              └── Team Member
                    └── Viewer (read-only)
                          └── Client (external, limited access)

The Permission Hook

type Permission =
  | "projects.create"
  | "projects.delete"
  | "members.invite"
  | "members.remove"
  | "billing.manage"
  | "content.approve"
  | "content.publish"
  | "analytics.view"
  | "settings.edit";

const ROLE_PERMISSIONS: Record<string, Permission[]> = {
  owner: ["*"],
  admin: [
    "projects.create", "projects.delete",
    "members.invite", "members.remove",
    "content.approve", "content.publish",
    "analytics.view", "settings.edit",
  ],
  manager: [
    "projects.create",
    "content.approve", "content.publish",
    "analytics.view",
  ],
  member: [
    "content.publish",
    "analytics.view",
  ],
  viewer: [
    "analytics.view",
  ],
};

function usePermissions() {
  const { user } = useAuth();
  const { tenant } = useTenant();

  const membership = useMemo(() => {
    if (!user || !tenant) return null;
    return user.memberships.find(m => m.organizationId === tenant.id);
  }, [user, tenant]);

  const can = useCallback((permission: Permission) => {
    if (!membership) return false;
    const rolePerms = ROLE_PERMISSIONS[membership.role];
    if (!rolePerms) return false;
    return rolePerms.includes("*") || rolePerms.includes(permission);
  }, [membership]);

  return { can, role: membership?.role };
}

Using It in Components

function ProjectActions({ project }: { project: Project }) {
  const { can } = usePermissions();

  return (
    <div>
      {can("content.publish") && (
        <Button onClick={() => publish(project.id)}>Publish</Button>
      )}
      {can("projects.delete") && (
        <Button variant="destructive" onClick={() => remove(project.id)}>
          Delete
        </Button>
      )}
    </div>
  );
}

Critical rule: Never hide a button and consider it "secured." The backend must enforce the same permission check. The frontend check is for UX — not security.

Scoping API Calls

Every API request in a multi-tenant app must include the tenant context. Instead of manually adding it to every fetch call, create an API layer that handles it automatically.

// services/api-client.ts
function createApiClient() {
  const instance = axios.create({
    baseURL: process.env.NEXT_PUBLIC_API_URL,
  });

  instance.interceptors.request.use((config) => {
    const tenantId = localStorage.getItem("activeTenantId");
    if (tenantId) {
      config.headers["X-Tenant-ID"] = tenantId;
    }
    return config;
  });

  return instance;
}

export const api = createApiClient();

Now every request automatically includes the tenant header. The backend uses it to scope all database queries. No developer can accidentally fetch another tenant's data because the scoping happens at the infrastructure level, not the feature level.

Feature Flags Per Tenant

Different plans get different features. Rendering a feature that the tenant hasn't paid for is bad UX. Worse, it creates support tickets.

// Feature-gated component
function ContentCalendar() {
  const { hasFeature } = useTenant();

  if (!hasFeature("content-calendar")) {
    return (
      <UpgradePrompt
        feature="Content Calendar"
        description="Schedule and manage content across all your social platforms."
        requiredPlan="professional"
      />
    );
  }

  return <CalendarView />;
}

// Feature-gated route
function AppRoutes() {
  const { hasFeature } = useTenant();

  return (
    <Routes>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/content" element={<ContentManager />} />
      {hasFeature("analytics") && (
        <Route path="/analytics" element={<AnalyticsDashboard />} />
      )}
      {hasFeature("commerce") && (
        <Route path="/commerce" element={<CommerceManager />} />
      )}
    </Routes>
  );
}

The navigation should also adapt — don't show sidebar links to features the tenant can't access. This keeps the UI clean and the upgrade prompts strategic.

Tenant Switching

When a user belongs to multiple organizations, they need to switch between them without losing their mental context.

function TenantSwitcher() {
  const { tenant, switchTenant } = useTenant();
  const { user } = useAuth();

  const organizations = user?.memberships.map(m => m.organization) ?? [];

  return (
    <DropdownMenu>
      <DropdownMenuTrigger>
        <div className="flex items-center gap-2">
          <span className="font-semibold text-sm">{tenant?.name}</span>
          <ChevronDown className="h-4 w-4" />
        </div>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        {organizations.map((org) => (
          <DropdownMenuItem
            key={org.id}
            onClick={() => switchTenant(org.id)}
          >
            {org.name}
            {org.id === tenant?.id && <Check className="h-4 w-4 ml-auto" />}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Key detail: When switching tenants, invalidate ALL cached queries. Stale data from the previous tenant showing up under the new tenant is a data leak — even if it's the same user.

The Patterns That Scale

After building multi-tenant frontends for social media platforms and enterprise tools, these are the patterns that consistently work:

  1. Tenant context at the root — every component and API call inherits the current tenant
  2. Permission checks at two levels — UI hides unauthorized actions, backend enforces them
  3. Automatic API scoping — tenant ID injected by interceptor, never manually
  4. Feature flags from tenant data — not hardcoded, loaded with the tenant object
  5. Query invalidation on tenant switch — prevent data leaks between organizations
  6. Upgrade prompts instead of hidden features — drives revenue, improves UX

Multi-tenant architecture is more than adding an organizationId to your database. It's a mindset that permeates every layer of your application. Get the frontend patterns right from day one, and your SaaS will scale smoothly to hundreds of tenants. Get them wrong, and you'll be refactoring under pressure as your biggest clients demand isolation guarantees you can't deliver.

multi-tenant
SaaS
RBAC
architecture
React
permissions
feature flags

Related Articles

Get started

Ready to Level Up Your Product?

I take on a handful of companies at a time. Reach out to discuss your challenges and see if there's a fit.

Send a Message