Frontend Permission Design: Routes, Buttons, Data Scope, and Feature Flags
How I wire backend-driven permission codes into dynamic routes, menus, action buttons, row-level scope, and gray releases in React admin apps.
A solid permission layer is easy to skip until a wrong button ships to production. In B2B dashboards and large consumer apps I treat permissions as backend-owned truth with a thin, consistent frontend adapter—not scattered if (role === 'admin') checks.
Permission types
| Layer | Examples | What it gates |
|---|---|---|
| Route | /admin, /finance | Page entry, deep links |
| Menu | Sidebar modules | What users discover |
| Button | Delete, export, settings | Destructive or sensitive actions |
| Data scope | Self / department / all | Rows returned by APIs |
| Gray / experiment | gray.newFeature | UI variants, staged rollouts |
Flow after login:
Login → user profile + permission codes → filter routes/menus → gate buttons → attach data scope to API calls
Backend payload shape
I prefer flat permission codes with a predictable prefix:
{
"user": {
"id": 1001,
"role": "admin",
"permissions": [
"page.user.list",
"page.user.edit",
"btn.user.create",
"btn.user.export",
"menu.finance",
"gray.newCheckout"
],
"dataScope": {
"type": "department",
"departmentIds": [101, 102]
}
}
}
Naming like page.*, btn.*, menu.*, gray.* keeps client code boring—in a good way.
Route guards
const routes = [
{
path: "/users",
meta: { permission: "page.user.list" },
component: () => import("@/pages/UserList"),
},
{
path: "/finance",
meta: { permission: "page.finance.view" },
component: () => import("@/pages/Finance"),
},
];
router.beforeEach((to, _from, next) => {
const required = to.meta.permission as string | undefined;
if (required && !permissions.includes(required)) {
return next("/403");
}
next();
});
In Next.js App Router, the same codes usually live in middleware or layout loaders that build the allowed segment tree once per session.
Menu filtering
Recursive filter so parent menus disappear when all children are denied:
function filterMenus<T extends { permission?: string; children?: T[] }>(
menus: T[],
permissions: string[],
): T[] {
return menus
.filter((m) => !m.permission || permissions.includes(m.permission))
.map((m) =>
m.children
? { ...m, children: filterMenus(m.children, permissions) }
: m,
);
}
Button-level control
Directive or wrapper—same check everywhere:
// React example
function PermissionButton({
permission,
children,
}: {
permission: string;
children: React.ReactNode;
}) {
if (!permissions.includes(permission)) return null;
return <>{children}</>;
}
<PermissionButton permission="btn.user.create">
<Button>Create user</Button>
</PermissionButton>
Hiding UI is not authorization by itself—the API must still enforce—but hiding prevents mistakes and support noise.
Data scope on requests
Attach scope once in the HTTP client:
api.interceptors.request.use((config) => {
config.headers["x-data-scope"] = JSON.stringify(user.dataScope);
return config;
});
The server interprets scope; the client does not re-implement row filters in every table.
Gray release
const showNewCheckout = permissions.includes("gray.newCheckout");
Often combined with experiment flags from a platform (user.flags) so product can run A/B without redeploying UI code.
Typical mismatch cases (worth documenting in QA)
| Symptom | Usually means |
|---|---|
| Page loads, delete missing | Route OK, button code missing |
| Menu visible, navigation 403 | Menu filter looser than router |
| Export wrong rows | Page OK, data scope wrong |
| New UI, old API | Gray UI on, API permission off |
How this fits my stack writing
Permissions sit next to config-driven UI platforms and SSR hydration governance—all three show up on long-lived React products I maintain for clients.