{"slug": "building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level", "title": "Building Dynamic RBAC in React 19: From Permission Strings to Component-Level Access Control", "summary": "The article explains how to replace hardcoded, string-based permission checks in React components with a type-safe, composable RBAC (Role-Based Access Control) layer. It advocates treating permissions as external configuration data that a backend engine resolves, rather than embedding role checks directly in the UI. The post provides TypeScript and FastAPI code examples to define permissions as typed constants and resolve them per user and tenant, enabling better testability, auditability, and scalability.", "body_md": "# Building Dynamic RBAC in React 19: From Permission Strings to Component-Level Access Control\n\nString-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.\n\nThe 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')`\n\ncheck, and create some Frankenstein conditional. Worse, there was no single source of truth for what \"feature_x_access\" actually meant across our tenant hierarchy.\n\nThis 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.\n\n## The Core Philosophy: Permissions Are Data, Not Logic\n\nDon't embed permissions in your component tree. Treat permissions as **configuration** that your components consume. This single shift unlocks everything.\n\nHere's what bad looks like:\n\n```\n// ❌ Don't do this\nexport function AIFeatureCard() {\n  const { user } = useAuth();\n\n  if (user.role !== 'admin' && user.role !== 'premium_subscriber') {\n    return null;\n  }\n\n  return <div>AI Feature</div>;\n}\n```\n\nProblems:\n\n- Role names are magic strings\n- Permission logic is fragmented across 20 components\n- Adding a new role? Find and update every check\n- No way to test permissions without rendering components\n- No audit trail of what permission was checked where\n\nHere's what good looks like:\n\n``` js\n// ✅ Do this\nconst canAccessAIFeature = await checkPermission('features:ai:access', {\n  userId,\n  tenantId,\n});\n\nif (canAccessAIFeature) {\n  return <AIFeatureCard />;\n}\n```\n\nNow permissions are data. You can log them, cache them, test them, audit them.\n\n## Type-Safe Permission Definitions\n\nStart with TypeScript. Define every permission your app has as a const object:\n\n``` js\n// permissions.ts\nexport const PERMISSIONS = {\n  // Organization management\n  'org:create': 'Create organization',\n  'org:update': 'Update organization settings',\n  'org:delete': 'Delete organization',\n  'org:invite_members': 'Invite team members',\n\n  // AI features (your feature gates)\n  'features:ai:access': 'Access any AI feature',\n  'features:ai:documents': 'Use document analysis',\n  'features:ai:workflows': 'Create automation workflows',\n  'features:ai:exports': 'Export AI-generated content',\n\n  // Admin\n  'admin:billing': 'Manage billing',\n  'admin:audit_logs': 'View audit logs',\n} as const;\n\n// Extract type: 'org:create' | 'org:update' | ...\nexport type PermissionKey = keyof typeof PERMISSIONS;\n```\n\nThis gives you autocomplete and catches typos at compile time. No more permission strings as magic text.\n\n## Building the Permission Engine\n\nYour permission engine lives on the backend and is queried from React. Here's the FastAPI service:\n\n``` python\n# permissions.py\nfrom enum import Enum\nfrom typing import Set\nfrom fastapi import Depends, HTTPException\nfrom sqlalchemy.orm import Session\n\nclass RoleType(str, Enum):\n    OWNER = \"owner\"\n    ADMIN = \"admin\"\n    MEMBER = \"member\"\n    GUEST = \"guest\"\n\n# Define role -> permissions mapping\nROLE_PERMISSIONS: dict[RoleType, Set[str]] = {\n    RoleType.OWNER: {\n        \"org:create\", \"org:update\", \"org:delete\", \"org:invite_members\",\n        \"features:ai:access\", \"features:ai:documents\", \"features:ai:workflows\",\n        \"features:ai:exports\", \"admin:billing\", \"admin:audit_logs\",\n    },\n    RoleType.ADMIN: {\n        \"org:update\", \"org:invite_members\",\n        \"features:ai:access\", \"features:ai:documents\", \"features:ai:workflows\",\n        \"features:ai:exports\", \"admin:audit_logs\",\n    },\n    RoleType.MEMBER: {\n        \"features:ai:access\", \"features:ai:documents\", \"features:ai:workflows\",\n    },\n    RoleType.GUEST: {\n        \"features:ai:documents\",  # Read-only\n    },\n}\n\n# Handle permission inheritance for multi-tenant\nclass PermissionResolver:\n    def __init__(self, db: Session):\n        self.db = db\n\n    async def get_user_permissions(\n        self, user_id: str, tenant_id: str\n    ) -> Set[str]:\n        \"\"\"Resolve all permissions for a user in a tenant.\"\"\"\n        # Query user role in this tenant\n        membership = self.db.query(TenantMembership).filter(\n            TenantMembership.user_id == user_id,\n            TenantMembership.tenant_id == tenant_id,\n        ).first()\n\n        if not membership:\n            return set()\n\n        # Start with their direct role permissions\n        permissions = ROLE_PERMISSIONS.get(membership.role, set()).copy()\n\n        # Add custom permissions (if you've granted individual perms)\n        custom = self.db.query(UserPermission).filter(\n            UserPermission.user_id == user_id,\n            UserPermission.tenant_id == tenant_id,\n        ).all()\n\n        for perm in custom:\n            permissions.add(perm.permission_key)\n\n        return permissions\n\n    async def can_perform(\n        self, user_id: str, tenant_id: str, permission: str\n    ) -> bool:\n        \"\"\"Check if user can perform an action.\"\"\"\n        permissions = await self.get_user_permissions(user_id, tenant_id)\n        return permission in permissions\n\n# Expose as endpoint\n@app.post(\"/api/permissions/check\")\nasync def check_permission(\n    request: CheckPermissionRequest,  # {permission, tenant_id}\n    user_id: str = Depends(get_current_user_id),\n    db: Session = Depends(get_db),\n) -> dict[str, bool]:\n    resolver = PermissionResolver(db)\n    allowed = await resolver.can_perform(\n        user_id, request.tenant_id, request.permission\n    )\n    return {\"allowed\": allowed}\n```\n\nNotice: **No logic in components.** All rules live in `ROLE_PERMISSIONS`\n\nand the database. When you add a feature, you update `ROLE_PERMISSIONS`\n\nonce. Done.\n\n## React Hook: usePermission\n\nNow the React side. Create a hook that queries your permission endpoint:\n\n``` js\n// usePermission.ts\nimport { useAuth } from './useAuth';\nimport { PermissionKey, PERMISSIONS } from './permissions';\n\ninterface UsePermissionOptions {\n  cacheSeconds?: number;\n}\n\nexport function usePermission(\n  permissionKey: PermissionKey,\n  options: UsePermissionOptions = {}\n) {\n  const { user, tenantId } = useAuth();\n  const [allowed, setAllowed] = React.useState<boolean | null>(null);\n  const [loading, setLoading] = React.useState(true);\n  const [error, setError] = React.useState<Error | null>(null);\n\n  React.useEffect(() => {\n    if (!user || !tenantId) {\n      setLoading(false);\n      setAllowed(false);\n      return;\n    }\n\n    const checkPerm = async () => {\n      try {\n        const response = await fetch('/api/permissions/check', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            permission: permissionKey,\n            tenant_id: tenantId,\n          }),\n        });\n\n        if (!response.ok) throw new Error('Permission check failed');\n        const data = await response.json();\n        setAllowed(data.allowed);\n      } catch (err) {\n        setError(err as Error);\n        setAllowed(false); // Fail closed\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    checkPerm();\n  }, [user?.id, tenantId, permissionKey]);\n\n  return { allowed, loading, error };\n}\n```\n\nUsage in components:\n\n```\n// AIFeatureCard.tsx\nexport function AIFeatureCard() {\n  const { allowed, loading } = usePermission('features:ai:access');\n\n  if (loading) return <Skeleton />;\n  if (!allowed) return null;\n\n  return <div className=\"p-4 bg-blue-50\">AI Feature</div>;\n}\n```\n\nClean. Testable. Type-safe.\n\n## Caching: The Performance Multiplier\n\nThis burned me: I shipped this without caching, and every component requesting the same permission hammered the backend. Use React Query:\n\n``` js\n// usePermission.ts (improved)\nimport { useQuery } from '@tanstack/react-query';\n\nexport function usePermission(\n  permissionKey: PermissionKey,\n  options: UsePermissionOptions = {}\n) {\n  const { user, tenantId } = useAuth();\n  const cacheSeconds = options.cacheSeconds ?? 300; // 5 min default\n\n  return useQuery({\n    queryKey: ['permission', tenantId, permissionKey],\n    queryFn: async () => {\n      const response = await fetch('/api/permissions/check', {\n        method: 'POST',\n        body: JSON.stringify({\n          permission: permissionKey,\n          tenant_id: tenantId,\n        }),\n      });\n      const data = await response.json();\n      return data.allowed;\n    },\n    staleTime: cacheSeconds * 1000,\n    enabled: !!user && !!tenantId,\n  });\n}\n```\n\nNow the second component asking for `features:ai:access`\n\nhits the cache, not your backend.", "url": "https://wpnews.pro/news/building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level", "canonical_source": "https://dev.to/uaslimcreate/building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level-access-control-14j9", "published_at": "2026-05-23 09:22:46+00:00", "updated_at": "2026-05-23 10:02:38.403920+00:00", "lang": "en", "topics": ["developer-tools", "enterprise-software", "data"], "entities": ["React", "CitizenApp", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level", "markdown": "https://wpnews.pro/news/building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level.md", "text": "https://wpnews.pro/news/building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level.txt", "jsonld": "https://wpnews.pro/news/building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level.jsonld"}}