Mingqi Hou

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

LayerExamplesWhat it gates
Route/admin, /financePage entry, deep links
MenuSidebar modulesWhat users discover
ButtonDelete, export, settingsDestructive or sensitive actions
Data scopeSelf / department / allRows returned by APIs
Gray / experimentgray.newFeatureUI 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.

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)

SymptomUsually means
Page loads, delete missingRoute OK, button code missing
Menu visible, navigation 403Menu filter looser than router
Export wrong rowsPage OK, data scope wrong
New UI, old APIGray 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.