Build route- and component-level permission gates that fail gracefully
By the end of this recipe you'll have a RequireRole wrapper for whole pages, a useRole hook for in-component checks (manager sees "Override", clerk doesn't), and a graceful "Manager only — ask Tendai" fallback page that beats a 404 every time.
Overview
Two gates at two levels. Route gates wrap whole pages and show a friendly "ask your manager" panel when the role doesn't match. Component gates hide or disable individual controls inline.
The biggest mistake new teams make is treating the JSX gate as a security boundary. It isn't — anyone with the bundle can flip a boolean. The gate is a UX nicety: it stops a clerk from clicking "Override price" and getting a confusing 403 from the server. The server still has to reject the request; the gate is what prevents the user ever sending it.
Gate server-side, not JSX-side. The JSX gate is a UX nicety — never a security boundary. Hiding a button isn't security; rejecting the request is. Both belong in the codebase; only the second one is allowed to be the only one.
When a permission check fails on a whole page, show the user something useful. A blank screen, a 404, or worse — a half-rendered page — all teach the user that the app is broken. The "Manager only" fallback names the role required and the manager on shift, so the user knows who to ask.
Skip this recipe if every user in the app has the same role. Permission gates exist to support role-based features; in a single-role product they're dead code.
What you'll build
A page seen by a manager, the same page denied to a clerk, and the row-level gate for a button.
Sale #2841
Manager · Tendai M.
Sale #2841
Clerk · Anesu S.
Manager features are hidden — not just disabled.
Payroll is locked to managers and owners. Tendai M. is on shift.
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: RequireRole, useRole, EmptyState, Button, Stack, RoleSwitcher. The hook and the wrapper share one identity context — they never disagree.
p-role-switcher primitive ships the pill segmented control with aria-pressed wiring — use it in demo previews and dev banners to flip identity.roleId without rewriting the toggle. Never ship it in production UI where the role is set by the back end.
Step-by-step build
Define the role model — roles are sets of permissions, not a string
Treat a role as a bag of Permission strings, not a single role name. That way the same gate works whether you grow from three roles to eight, and you can grant a clerk a single elevated permission without inventing "clerk-plus".
extraPermissions lets you grant a clerk "refund" without renaming them.
The server enforces the same map. JSX gates only mirror.
Switch to the Code tab to see the snippet.
Build the useRole hook — the single source of truth
The hook reads the current identity from context, then exposes can(p) and a literal role for label purposes. Components use can; the gate wrapper uses the same primitive so they can't disagree.
When identity is null, every can() returns false.
extraPermissions are merged at read time — no role mutation.
Switch to the Code tab to see the snippet.
Build the in-component gate — hide, don't disable
If the user can't do something, the button isn't there. A disabled button is a tease — it advertises a feature the user can never use, and forces them to inspect it to find out why. The exception is when the user could realistically be promoted — then disable + tooltip.
"Override price" is hidden from clerks — secret, not teased.
"Refund" is disabled with a tooltip — clerks know it exists.
Switch to the Code tab to see the snippet.
Build the route-level gate — fall back to "ask Tendai"
Wrap the page in RequireRole. When the check fails, render a friendly empty state that names the role required and the on-shift manager. Never a 404; never a blank screen; never the same page with everything disabled.
Manager only
Tendai M. is on shift and can open this for you.
Signed in as Anesu S.
Final composition
The whole identity layer, assembled. Provider + hook + route gate + in-component gates + a sample router setup.
Sale #2841
Manager · Tendai M.Variations
Three common forks. Each is a small diff from the final composition.
Time-of-day gate
Refunds disallowed after 6pm without an owner sign-off. Add a temporal predicate to the permission check.
const allowed = can('sale.refund') && (
new Date().getHours() < 18
|| can('sale.refund.afterhours')
);
// rest of the gate is identical
Owner sign-off prompt
Instead of "ask the manager", let the clerk request the action — owner gets a push, approves, the action runs.
// Replace EmptyState with a request form
<ApprovalRequest
permission={permission}
managerOnShift={managerOnShift}
onSubmit={(reason) =>
api.requestOverride(reason)}
/>
Multi-permission gate
Some pages need any-of permissions (manager OR finance). Accept an array and treat as union.
function RequireAny({ permissions, ... }) {
const { can } = useRole();
if (permissions.some(can)) {
return <>{children}</>;
}
return <EmptyState ... />;
}
Accessibility
What this recipe takes care of for you.
- The deny screen is announced.
EmptyStaterendersrole="region"+aria-labelledbyon its heading, so screen readers announce "Manager only" when the gate triggers. - Disabled buttons read their reason. A disabled "Refund" carries
aria-describedbypointing at a hidden span — screen readers say "Refund, dimmed, refunds need a manager". - The "ask Tendai" CTA is a real button. Not a link styled like a button.
Enterfires the handoff request; focus stays predictable. - Hidden buttons stay hidden. A button hidden via
return nullis removed from the DOM entirely, so it can't be reached by Tab or read by a screen reader. Don'tvisibility: hiddena permission-gated control. - The manager's name is visible, not just spoken. Sighted users see who to ask without focusing the CTA. Screen-reader users hear the same name through the description.
- The server is the truth. A failed server response renders the same
EmptyStatevia the global error boundary, so a stale client never traps a user on a broken page.