Search posts

Posts

Search approved public post titles.

    Back to posts

    5 Minutes into Frontend RBAC

    Roles, permissions, and where the easy answers run out.

    When it comes to permissions (RBAC) on the frontend, are you just hiding a button with if (role === 'ADMIN') and calling it done?

    It works. Until it doesn’t. So before the patterns, the one rule most tutorials skip:

    That’s your Single Source of Truth (SSOT). Keep that boundary clean and everything below is about experience. Blur it, and you’ve shipped a lock with the key taped to the door.

    Trust boundary between the frontend browser, which decides what to show and can be bypassed in devtools, and the backend server, which decides what is allowed and is the single source of truth.

    The frontend decides what to show; the backend decides what’s allowed.

    Now, the three patterns.

    Approach 1. Static Route Mapping (Config-Driven)

    The simplest approach maps roles to routes.

    ADMIN  -> Dashboard, Users
    EDITOR -> Dashboard
    VIEWER -> Dashboard (read only)

    It works perfectly on day one. Then the requirements change.

    A role needs access to part of a page. A new edge case fits none of your existing roles. Marketing wants editors to see one admin-only widget.

    Now the role checks don’t live in one config file. They scatter across components, routes, menus, and feature flags.

    The coupling grows quietly and you only notice the day you add one new role and break four screens you forgot existed.

    Good for: small apps with few, stable roles.
    Painful when: roles multiply or permissions get granular.

    Approach 2. Server-Driven Navigation (Backend-Driven UI)

    Here, the server decides. Instead of hardcoding menus, the frontend renders whatever the backend sends down—the navigation, the visible features, the allowed actions.

    This shines in B2B SaaS, where every customer has a different plan and different permissions. You can’t hardcode that.

    The trade-off is timing: a permission-aware UI can’t fully render until the payload arrives. But that’s not the blank screen a lot of write-ups make it out to be. Your shell and skeletons paint immediately, and caching the last known set lets returning users see their real UI right away.

    A trade-off you manage, not a wall you hit.

    Good for: multi-tenant products with per-customer permissions.
    Painful when: you treat the permission fetch as blocking instead of progressive.

    Approach 3. Granular Permissions

    The third approach stops shipping roles to the frontend and ships a flat array of permissions instead.

    ["view:dashboard", "edit:users", "delete:comments"]

    Now components don’t care who you are. They ask one question:

    Do I have this permission?

    <Can permission="edit:users">
      <EditUserButton />
    </Can>

    This is decoupling. The component knows nothing about admins, editors, or viewers. Only a single capability. Drop it anywhere, test it in isolation, reuse it without rewiring a role matrix.

    It’s still RBAC. The roles don’t disappear. They move.

    The backend keeps them, maps each one to a set of permissions, and sends down the resolved flat array. Roles stay a backend concern; permissions become the language the frontend speaks.

    One honest cost: those strings multiply and aren’t type-checked. Typo "edit:users" and nothing complains until runtime. Govern them with a central registry or generated types before they drift.

    Before You Go: A Few Things Worth Thinking About

    These three patterns will carry most apps a long way. But the moment your product has real users and real data, a few questions show up that none of them answer cleanly.

    I won’t solve them here. The answers deserve their own article, and honestly, the thinking is the valuable part. So let’s just sit with them.

    1. What happens when a permission is revoked mid-action?

      A user is three fields into a form when an admin pulls their access. The backend will reject the save—fine, that’s the SSOT doing its job. But should the UI let them keep typing into a dead end? How does the frontend even find out in real time, without hammering the server? And once it knows, how do you take access away without yanking half-finished work off the screen?

    2. What do you trust when the network drops?

      Offline-first apps can’t refetch permissions on demand. Do you trust the last permission set you saw and risk acting on stale rules? And if the user makes a change you can’t sync, where does that work go? Throwing it away is the easy answer. It’s also the wrong one.

    3. What about “I can only edit my own posts”?

      This is the sneaky one. edit:posts tells you the user can edit posts. Not whether they can edit this post. The check quietly becomes can("edit:posts") && post.authorId === currentUser.id. The moment your permissions depend on the resource and not just the user, plain RBAC starts to creak, and you’re standing at the doorway of a different model entirely.

    None of these has a single right answer. They have trade-offs, which is exactly what makes them worth thinking through before you hit them in production.

    So I’ll leave you with the question instead of the answer:

    How would you handle each of these in your own app?

    (That third one, in particular, is where I’d like to pick things up next time.)

    Comments

    Comments require JavaScript. The article remains available without it.