Building Dynamic RBAC in React 19: From Permission Strings to Component-Level Access Control
String-based permission checks scattered across your React codebase are a maintenance nightmare. I know because I shipped CitizenApp with that anti-pattern, and it nearly bit me when we added our fifth AI feature.
The problem? Permissions were hardcoded in components. When the marketing team wanted to trial a feature with select customers, I had to grep through half the codebase, find every if (user.role === 'admin') check, and create some Frankenstein conditional. Worse, there was no single source of truth for what "feature_x_access" actually meant across our tenant hierarchy.
This post shows you how to build a type-safe, composable RBAC layer that lives outside your components. Your UI asks "can I do X?" and the permission engine answers. Clean separation. Testable. Scales.
The Core Philosophy: Permissions Are Data, Not Logic
Don't embed permissions in your component tree. Treat permissions as configuration that your components consume. This single shift unlocks everything.
Here's what bad looks like:
// ❌ Don't do this
export function AIFeatureCard() {
const { user } = useAuth();
if (user.role !== 'admin' && user.role !== 'premium_subscriber') {
return null;
}
return <div>AI Feature</div>;
}
Problems:
- Role names are magic strings
- Permission logic is fragmented across 20 components
- Adding a new role? Find and update every check
- No way to test permissions without rendering components
- No audit trail of what permission was checked where
Here's what good looks like:
// ✅ Do this
const canAccessAIFeature = await checkPermission('features:ai:access', {
userId,
tenantId,
});
if (canAccessAIFeature) {
return <AIFeatureCard />;
}
Now permissions are data. You can log them, cache them, test them, audit them.
Type-Safe Permission Definitions
Start with TypeScript. Define every permission your app has as a const object:
// permissions.ts
export const PERMISSIONS = {
// Organization management
'org:create': 'Create organization',
'org:update': 'Update organization settings',
'org:delete': 'Delete organization',
'org:invite_members': 'Invite team members',
// AI features (your feature gates)
'features:ai:access': 'Access any AI feature',
'features:ai:documents': 'Use document analysis',
'features:ai:workflows': 'Create automation workflows',
'features:ai:exports': 'Export AI-generated content',
// Admin
'admin:billing': 'Manage billing',
'admin:audit_logs': 'View audit logs',
} as const;
// Extract type: 'org:create' | 'org:update' | ...
export type PermissionKey = keyof typeof PERMISSIONS;
This gives you autocomplete and catches typos at compile time. No more permission strings as magic text.
Building the Permission Engine
Your permission engine lives on the backend and is queried from React. Here's the FastAPI service:
# permissions.py
from enum import Enum
from typing import Set
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
class RoleType(str, Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
GUEST = "guest"
# Define role -> permissions mapping
ROLE_PERMISSIONS: dict[RoleType, Set[str]] = {
RoleType.OWNER: {
"org:create", "org:update", "org:delete", "org:invite_members",
"features:ai:access", "features:ai:documents", "features:ai:workflows",
"features:ai:exports", "admin:billing", "admin:audit_logs",
},
RoleType.ADMIN: {
"org:update", "org:invite_members",
"features:ai:access", "features:ai:documents", "features:ai:workflows",
"features:ai:exports", "admin:audit_logs",
},
RoleType.MEMBER: {
"features:ai:access", "features:ai:documents", "features:ai:workflows",
},
RoleType.GUEST: {
"features:ai:documents", # Read-only
},
}
# Handle permission inheritance for multi-tenant
class PermissionResolver:
def __init__(self, db: Session):
self.db = db
async def get_user_permissions(
self, user_id: str, tenant_id: str
) -> Set[str]:
"""Resolve all permissions for a user in a tenant."""
# Query user role in this tenant
membership = self.db.query(TenantMembership).filter(
TenantMembership.user_id == user_id,
TenantMembership.tenant_id == tenant_id,
).first()
if not membership:
return set()
# Start with their direct role permissions
permissions = ROLE_PERMISSIONS.get(membership.role, set()).copy()
# Add custom permissions (if you've granted individual perms)
custom = self.db.query(UserPermission).filter(
UserPermission.user_id == user_id,
UserPermission.tenant_id == tenant_id,
).all()
for perm in custom:
permissions.add(perm.permission_key)
return permissions
async def can_perform(
self, user_id: str, tenant_id: str, permission: str
) -> bool:
"""Check if user can perform an action."""
permissions = await self.get_user_permissions(user_id, tenant_id)
return permission in permissions
# Expose as endpoint
@app.post("/api/permissions/check")
async def check_permission(
request: CheckPermissionRequest, # {permission, tenant_id}
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db),
) -> dict[str, bool]:
resolver = PermissionResolver(db)
allowed = await resolver.can_perform(
user_id, request.tenant_id, request.permission
)
return {"allowed": allowed}
Notice: No logic in components. All rules live in ROLE_PERMISSIONS and the database. When you add a feature, you update ROLE_PERMISSIONS once. Done.
React Hook: usePermission
Now the React side. Create a hook that queries your permission endpoint:
// usePermission.ts
import { useAuth } from './useAuth';
import { PermissionKey, PERMISSIONS } from './permissions';
interface UsePermissionOptions {
cacheSeconds?: number;
}
export function usePermission(
permissionKey: PermissionKey,
options: UsePermissionOptions = {}
) {
const { user, tenantId } = useAuth();
const [allowed, setAllowed] = React.useState<boolean | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
React.useEffect(() => {
if (!user || !tenantId) {
setLoading(false);
setAllowed(false);
return;
}
const checkPerm = async () => {
try {
const response = await fetch('/api/permissions/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
permission: permissionKey,
tenant_id: tenantId,
}),
});
if (!response.ok) throw new Error('Permission check failed');
const data = await response.json();
setAllowed(data.allowed);
} catch (err) {
setError(err as Error);
setAllowed(false); // Fail closed
} finally {
setLoading(false);
}
};
checkPerm();
}, [user?.id, tenantId, permissionKey]);
return { allowed, loading, error };
}
Usage in components:
// AIFeatureCard.tsx
export function AIFeatureCard() {
const { allowed, loading } = usePermission('features:ai:access');
if (loading) return <Skeleton />;
if (!allowed) return null;
return <div className="p-4 bg-blue-50">AI Feature</div>;
}
Clean. Testable. Type-safe.
Caching: The Performance Multiplier
This burned me: I shipped this without caching, and every component requesting the same permission hammered the backend. Use React Query:
// usePermission.ts (improved)
import { useQuery } from '@tanstack/react-query';
export function usePermission(
permissionKey: PermissionKey,
options: UsePermissionOptions = {}
) {
const { user, tenantId } = useAuth();
const cacheSeconds = options.cacheSeconds ?? 300; // 5 min default
return useQuery({
queryKey: ['permission', tenantId, permissionKey],
queryFn: async () => {
const response = await fetch('/api/permissions/check', {
method: 'POST',
body: JSON.stringify({
permission: permissionKey,
tenant_id: tenantId,
}),
});
const data = await response.json();
return data.allowed;
},
staleTime: cacheSeconds * 1000,
enabled: !!user && !!tenantId,
});
}
Now the second component asking for features:ai:access hits the cache, not your backend.























