Shared State Management
Permission-Based Routing in Micro Frontend

Published: May 31, 2026 · 14 min read

Permission-Based Routing in React Micro Frontend

Setting up permission based routing react micro frontend patterns starts with a question that does not exist in a single SPA: what happens when a user types /earnings into the URL bar? In a federated host, every remote is mounted by a route — <Route path="earnings/*" element={<EarningsMFE />} /> — and that route renders for anyone who reaches it. A support agent who only needs the Support module can navigate to Earnings, watch the whole remote bundle download, and land on a payouts dashboard they were never meant to see. The nav bar made it worse by showing all ten menu items to everyone. Authorization in a micro frontend is not a single if (isAdmin) check; it is a system that spans the shared store, the host router, the menu config, and every remote. This article builds that system on top of the shared Redux store, slices, and custom hooks from the previous articles.

The pattern has four moving parts: a flat permissions array in the shared auth slice, a usePermissions hook every remote imports, a <PermissionRoute> guard that wraps each remote route, and a permission-aware menu config that hides what the user cannot reach. By the end you will see how all four read from one source so they can never drift apart.

In this guide, you will:

  • See why every remote route is wide open by default and what that costs in an MFE
  • Flatten the backend's nested permissions tree into a flat array of keys at the auth boundary
  • Store permissions in the shared Redux auth slice as a federated singleton
  • Build a usePermissions hook with can, hasAny, and hasAll checks
  • Wrap each remote route in a <PermissionRoute> guard that redirects before the bundle loads
  • Drive the nav menu and the guards from one config so they stay in sync
  • Load permissions differently in local development vs production with Tabs
  • Layer route guards, menu filters, and component gates — and why none of them replace backend checks

Permission based routing react micro frontend architecture diagram showing flat permissions array in shared Redux auth slice feeding PermissionRoute guard menu filter and component gate across host and remotes

Why Every Remote Route Is Wide Open

In a monolithic SPA, route guards are simple because one team owns every route and the user object. In a micro frontend, the host mounts each remote by path and the remote is a separately deployed bundle. The host's router does not know — and should not need to know — what each remote does internally. That separation is the whole point of host and remote apps, but it means the default <Route> renders its remote unconditionally.

The 'every remote route is wide open' problem
# The "Every Remote Route Is Wide Open" Problem
# ──────────────────────────────────────────────────
#
#   A federated host mounts ten remotes by path:
#
#   ┌────────────────────────────────────────────────────────┐
#   │  apps/Main/src/App.jsx  (the host router)              │
#   │                                                        │
#   │  <Route path="pricing/*"   element={<PricingMFE />} /> │
#   │  <Route path="orders/*"    element={<OrdersMFE />} />  │
#   │  <Route path="reports/*"   element={<ReportsMFE />} /> │
#   │  <Route path="earnings/*"  element={<EarningsMFE />} />│
#   │  ... seven more remotes, all mounted the same way      │
#   └────────────────────────────────────────────────────────┘
#
#   THE PROBLEM:
#     - A "support agent" user logs in and types /earnings in
#       the URL bar. The EarningsMFE loads. They see payouts.
#     - A "catalog editor" opens /reports and the whole
#       ReportsMFE downloads and renders — no role check anywhere.
#     - The nav bar shows all ten menu items to everyone, so
#       users click into modules they cannot use and hit raw
#       403s from the backend instead of a clean redirect.
#
#   WHY IT HAPPENS:
#     Each remote is a separately deployed bundle. The host
#     mounts it by route. Nothing in <Route> knows what the
#     current user is allowed to see — the route just renders.
#
#   THE FIX (this article):
#     1. Backend returns a nested permissions tree at login.
#     2. The host FLATTENS it to a flat array of string keys.
#     3. The flat array lives in the shared Redux auth slice.
#     4. A <PermissionRoute> wraps every remote route and
#        redirects when the key is missing.
#     5. The menu config hides items the user cannot access.

The fix is to make authorization a first-class concern of the host's routing layer, fed by data the backend owns. The user's permissions ride in the same shared store that already carries authentication state, and the host's router consults them before rendering any remote.

Flattening the Backend Permissions Tree

The backend models permissions as a nested tree — one node per module, each with boolean flags and a list of submenus. That shape maps cleanly to the admin UI where roles are edited, but it is awful to check on every render.

seller-profile API response — nested permissions tree
// The seller-profile API returns permissions as a NESTED tree,
// one node per module, each with boolean flags and submenus.
// This is the raw shape the backend owns — the host never
// route-matches against this directly.
{
  "user": { "first_name": "Asha", "last_name": "Rao" },
  "permissions": [
    {
      "module": "inventory",
      "view": true,
      "edit": true,
      "submenus": [
        { "submenu": "stock", "view": true, "adjust": false }
      ]
    },
    {
      "module": "orders",
      "view": true,
      "edit": false,
      "submenus": [
        { "submenu": "returns", "view": true, "approve": false }
      ]
    },
    {
      "module": "earnings",
      "view": false,                 // <- no access to Earnings
      "submenus": []
    }
  ]
}

Route guards run constantly, so they need an O(1) check, not a recursive tree walk. The host flattens the tree once, at the moment the profile loads, into a flat array of string keys like inventory_view and orders_returns_approve. Every flag that is false is simply absent — presence in the array is the grant, so there is no "deny" entry to reason about.

apps/Main/src/utils/flattenSellerPermissions.js
// File: apps/Main/src/utils/flattenSellerPermissions.js
// Turn the nested permissions tree into a FLAT array of string
// keys. Route guards and menu filters do a single
// permissions.includes('orders_view') check — O(1) intent,
// no tree-walking at every render.
export function flattenPermissions(permsArray) {
  const out = [];

  permsArray.forEach((mod) => {
    const modName = mod.module;

    // top-level flags: { module: 'inventory', view: true, edit: true }
    //   -> 'inventory_view', 'inventory_edit'
    Object.entries(mod).forEach(([key, val]) => {
      if (key === 'module' || key === 'submenus') return;
      if (val) out.push(modName + '_' + key);
    });

    // submenu flags: { submenu: 'stock', view: true }
    //   -> 'inventory_stock_view'
    mod.submenus.forEach((sub) => {
      const subName = sub.submenu;
      Object.entries(sub).forEach(([key, val]) => {
        if (key === 'submenu') return;
        if (val) out.push(modName + '_' + subName + '_' + key);
      });
    });
  });

  return out;
  // Result for the payload above:
  // [ 'inventory_view', 'inventory_edit', 'inventory_stock_view',
  //   'orders_view', 'orders_returns_view' ]
  // Note: every flag that is false is simply absent. Presence in
  // the array IS the grant — there is no "deny" entry to reason about.
}

This is the single most important design choice in the whole system: flatten at the edge, check flat everywhere else. The nested tree never leaves the auth boundary; the rest of the app — guards, menus, component gates — only ever sees the flat array.

Permission flattening flow in micro frontend showing nested backend permissions tree transformed into flat array of string keys then dispatched into shared Redux auth slice

Storing Permissions in the Shared Auth Slice

Permissions do not get their own store. They live in the same auth slice every remote already reads for isLoggedIn and the access token, covered in the shared Redux store article. Because that slice is federated as a Module Federation singleton, every remote reads the identical array from one store instance.

packages/core/store/src/slices/authSlice.js
// File: packages/core/store/src/slices/authSlice.js
// Permissions live in the SAME shared auth slice every remote
// already reads for isLoggedIn and the access token. There is
// no separate "permissions store" — one slice, one source of
// truth, federated as a singleton across all remotes.
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  isLoggedIn: false,
  user: null,
  permissions: [],          // flat array of string keys
  authToken: null,
  loading: true,
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    // Called once after login, with the ALREADY-FLATTENED array.
    setUserInfo: (state, action) => {
      state.user = action.payload.user;
      state.permissions = action.payload.permissions || [];
    },
    setIsLoggedIn: (state, action) => {
      state.isLoggedIn = action.payload.isLoggedIn;
    },
    setAuthToken: (state, action) => {
      state.authToken = action.payload.authToken;
    },
  },
});

export const { setUserInfo, setIsLoggedIn, setAuthToken } = authSlice.actions;

// The single selector every guard, menu, and component reads.
export const selectPermissions = (state) => state.auth.permissions;
export const selectIsLoggedIn  = (state) => state.auth.isLoggedIn;

export default authSlice.reducer;

The setUserInfo reducer takes the already-flattened array — flattening is the host's job at the boundary, not the slice's. The slice stays dumb: it stores what it is given and exposes one selectPermissions selector.

⚠️

Client-side permissions are a UX layer, not a security boundary. The flat array, the menu filter, and the route guard all live in JavaScript the user controls — they can edit Redux state in DevTools. Every check shown here MUST be mirrored by server-side authorization on the protected API endpoints. The front end gives the right user a clean experience; the backend is what actually stops the wrong user.

The usePermissions Hook

Following the custom hooks pattern, every remote imports one usePermissions hook from the shared store package instead of reading selectPermissions directly. The hook returns the three checks guards actually need.

packages/core/store/src/hooks/usePermissions.js
// File: packages/core/store/src/hooks/usePermissions.js
// One hook, exported from the shared store package, that every
// remote imports. It wraps selectPermissions and returns the
// three checks guards actually need. Built on the useAppSelector
// foundation from the custom-hooks article.
import { useMemo } from 'react';
import { useAppSelector } from '../hooks';
import { selectPermissions } from '../slices/authSlice';

export const usePermissions = () => {
  const permissions = useAppSelector(selectPermissions);

  // useMemo so 'can', 'hasAny', 'hasAll' keep stable references
  // across renders — a consumer passing 'can' into a child's
  // props or a useEffect dep array does not re-render on every
  // unrelated dispatch.
  return useMemo(() => {
    const set = new Set(permissions);     // O(1) lookups

    const can    = (key)  => set.has(key);
    const hasAny = (keys) => keys.some((k) => set.has(k));
    const hasAll = (keys) => keys.every((k) => set.has(k));

    return { permissions, can, hasAny, hasAll };
  }, [permissions]);
};

// USAGE — inside any remote component:
//   const { can } = usePermissions();
//   if (!can('orders_edit')) return <ReadOnlyBanner />;
//
// WHY A HOOK AND NOT RAW useSelector EVERYWHERE:
//   The remote imports usePermissions, never selectPermissions.
//   If the slice field renames (permissions -> grants), only this
//   hook changes — the dozens of guard call sites stay untouched.
//   Same contract discipline as every other shared-store hook.

Wrapping the return in useMemo keyed on permissions keeps can, hasAny, and hasAll referentially stable across renders, and converting the array to a Set makes each check O(1). When the slice field eventually renames from permissions to grants, only this hook changes — the dozens of guard call sites across every remote stay untouched.

The PermissionRoute Guard

The guard is a thin wrapper component placed as the parent of the remote's <Suspense> boundary. If the user lacks the key, it redirects instead of rendering.

apps/Main/src/components/PermissionRoute.jsx
// File: apps/Main/src/components/PermissionRoute.jsx
// The route guard. Wraps a single remote route. If the user is
// missing the required key, it redirects to a path they CAN see
// instead of rendering the remote (and downloading its bundle).
import React from 'react';
import { Navigate } from 'react-router-dom';
import { usePermissions } from '@myapp/store';

const PermissionRoute = ({ permissionKey, children, fallbackPath }) => {
  const { can } = usePermissions();

  if (!can(permissionKey)) {
    // 'replace' so the blocked URL does not stay in history —
    // the back button must not bounce the user back to a 403.
    return <Navigate to={fallbackPath || '/home'} replace />;
  }

  return children;
};

export default PermissionRoute;

The replace prop on <Navigate> is deliberate: it keeps the blocked URL out of history so the back button cannot bounce the user back into a redirect loop. The subtle win is bandwidth — because the guard is the parent of the <Suspense> and the lazy() remote, React never commits the lazy component for a redirected route, so the remote's remoteEntry chunk is never fetched for an unauthorized user.

One Config for Menu and Guards

The most common source of authorization bugs is the menu and the guards drifting apart — a menu item that links to a route the guard blocks, or a route with no menu entry. The fix is to drive both from one config where each item declares its permissionKey.

apps/Main/src/config/menu.config.js
// File: apps/Main/src/config/menu.config.js
// ONE config drives both the nav bar AND the route guards.
// Each menu item declares the permissionKey that unlocks it,
// so the menu filter and the <PermissionRoute> can never drift
// out of sync — they read the same source.
export const MAIN_MENU_CONFIG = [
  { id: 'home',      label: 'Home',      path: '/home',      permissionKey: 'home_view' },
  { id: 'inventory', label: 'Inventory', path: '/inventory', permissionKey: 'inventory_view' },
  { id: 'products',  label: 'Products',  path: '/products',  permissionKey: 'products_view' },
  { id: 'orders',    label: 'Orders',    path: '/orders',    permissionKey: 'orders_view' },
  { id: 'pricing',   label: 'Pricing',   path: '/pricing',   permissionKey: 'pricing_view' },
  { id: 'earnings',  label: 'Earnings',  path: '/earnings',  permissionKey: 'earnings_view' },
  { id: 'reports',   label: 'Reports',   path: '/reports',   permissionKey: 'reports_view' },
  { id: 'settings',  label: 'Settings',  path: '/settings',  permissionKey: 'settings_view' },
  { id: 'support',   label: 'Support',   path: '/support',   permissionKey: 'support_view' },
];

The host header filters this exact config to render the nav bar. An item with no matching permission disappears from the menu and — because the route guard reads the same permissionKey — its route redirects too.

apps/Main/src/components/layout/Header.jsx
// File: apps/Main/src/components/layout/Header.jsx
// The nav bar renders only the items the user can access.
// Hidden menu items are the FIRST layer of defense — but never
// the only one (a user can still type the URL). The route guard
// is the real gate; the menu filter is the UX.
import { usePermissions } from '@myapp/store';
import { MAIN_MENU_CONFIG } from '../../config/menu.config';

export const Header = ({ activeMenu, onMenuClick }) => {
  const { can } = usePermissions();

  // No permissionKey on an item => always visible.
  const visibleItems = MAIN_MENU_CONFIG.filter(
    (item) => !item.permissionKey || can(item.permissionKey)
  );

  return (
    <nav className="host-nav">
      {visibleItems.map((item) => (
        <button
          key={item.id}
          onClick={() => onMenuClick(item.id)}
          className={activeMenu === item.id ? 'is-active' : ''}
        >
          {item.label}
        </button>
      ))}
    </nav>
  );
};

Now the routing layer wraps every remote route in <PermissionRoute> and uses the same config to compute a safe landing redirect — never a hard-coded /home that a support agent might not have access to.

apps/Main/src/App.jsx — guarded remote routes
// File: apps/Main/src/App.jsx
// Every remote route is wrapped in <PermissionRoute>. The guard
// runs BEFORE <Suspense> resolves the remote, so a blocked user
// never triggers the dynamic import of the remote's bundle.
import { Routes, Route, Navigate } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { usePermissions } from '@myapp/store';
import { MAIN_MENU_CONFIG } from './config/menu.config';
import PermissionRoute from './components/PermissionRoute';

const OrdersMFE   = lazy(() => import('OrdersManagement/OrdersMFE'));
const EarningsMFE = lazy(() => import('EarningsManagement/EarningsMFE'));
const ReportsMFE  = lazy(() => import('ReportsManagement/ReportsMFE'));

const App = () => {
  const { permissions } = usePermissions();

  // The landing redirect must send the user to a route they CAN
  // see — never a hard-coded '/home' a support agent lacks.
  const firstAllowedPath = () => {
    const allowed = MAIN_MENU_CONFIG.filter((m) =>
      permissions.includes(m.permissionKey)
    );
    return allowed[0]?.path || '/home';
  };

  return (
    <Routes>
      <Route path="/" element={<Navigate to={firstAllowedPath()} replace />} />

      <Route path="orders/*" element={
        <PermissionRoute permissionKey="orders_view" fallbackPath={firstAllowedPath()}>
          <Suspense fallback={<Loading />}><OrdersMFE /></Suspense>
        </PermissionRoute>
      } />

      <Route path="earnings/*" element={
        <PermissionRoute permissionKey="earnings_view" fallbackPath={firstAllowedPath()}>
          <Suspense fallback={<Loading />}><EarningsMFE /></Suspense>
        </PermissionRoute>
      } />

      <Route path="reports/*" element={
        <PermissionRoute permissionKey="reports_view" fallbackPath={firstAllowedPath()}>
          <Suspense fallback={<Loading />}><ReportsMFE /></Suspense>
        </PermissionRoute>
      } />

      <Route path="*" element={<Navigate to={firstAllowedPath()} replace />} />
    </Routes>
  );
};

The firstAllowedPath() helper is what makes the "/" redirect safe across roles: it filters the menu config by the user's permissions and sends them to the first module they can actually open.

Loading Permissions: Local vs Production

How permissions get into the store differs sharply between environments. Locally there is no auth server, so you seed a fixed role from an env file. In production the host runs the real refresh-token and profile flow, receives the nested tree, and flattens it. The store shape is identical — only the loading path changes.

Local seeds a fixed permission set; production fetches and flattens the real tree. Keep the dev branch behind an env flag so it can never ship — a seeded DEV_PERMISSIONS array in production would grant everyone the same role.

apps/Main/src/App.jsx — local dev auth
// File: apps/Main/src/App.jsx (LOCAL development)
// Locally there is no real auth server. Seed a fixed permission
// set from an env file so every developer reproduces the same
// role while building. NEVER ship this branch to production.
import { useEffect } from 'react';
import { useAppDispatch, setUserInfo, setIsLoggedIn } from '@myapp/store';

const DEV_PERMISSIONS = (process.env.DEV_PERMISSIONS || '')
  .split(',')
  .filter(Boolean);
// .env.local ->
//   DEV_PERMISSIONS=home_view,inventory_view,orders_view,products_view

const useDevAuth = () => {
  const dispatch = useAppDispatch();
  useEffect(() => {
    dispatch(setUserInfo({
      user: { first_name: 'Dev', last_name: 'User' },
      permissions: DEV_PERMISSIONS,        // already flat
    }));
    dispatch(setIsLoggedIn({ isLoggedIn: true }));
  }, [dispatch]);
};

The differences that matter:

AspectLocal DevelopmentProduction / Server
Permission sourceDEV_PERMISSIONS env stringProfile API nested tree
FlatteningNot needed (already flat)flattenPermissions() at boundary
Auth tokenSkipped or mockedSilent refresh-token call
Role switchingEdit .env.local, reloadBackend role assignment
RiskMust never ship to prodReal authorization source

Three Layers of Permission Checks

A complete system applies the same flat keys at three different scopes. The route guard prevents loading a remote; inside an allowed remote, a component gate controls individual actions.

apps/OrdersManagement/src/components/OrderActions.jsx
// File: apps/OrdersManagement/src/components/OrderActions.jsx
// The THIRD layer — gating an action WITHIN an already-allowed
// remote. A user with 'orders_view' can open Orders, but only a
// user with 'orders_edit' sees the "Approve refund" button.
import { usePermissions } from '@myapp/store';

export default function OrderActions({ order }) {
  const { can } = usePermissions();

  return (
    <div className="order-actions">
      <button>View invoice</button>

      {/* Action-level gate inside the remote */}
      {can('orders_edit') && (
        <button onClick={() => approveRefund(order.id)}>
          Approve refund
        </button>
      )}

      {can('orders_returns_approve') && (
        <button onClick={() => approveReturn(order.id)}>
          Approve return
        </button>
      )}
    </div>
  );
}

A user with orders_view opens the Orders remote, but only a user with orders_edit sees the "Approve refund" button. Here is how the three front-end layers compare — and why all three still sit on top of backend enforcement.

Three layers of permission based routing react micro frontend showing menu filter route guard and component gate stacked on top of backend authorization boundary

LayerScopeWhat it doesIs it security?
Menu filterWhole nav itemHides links the user cannot useNo — UX only
Route guard (<PermissionRoute>)Whole remote MFERedirects, blocks bundle loadPartial — front-end gate
Component gate (can(...))Single action/buttonHides edit actions in an allowed remoteNo — UX only
Backend authorizationAPI endpointRejects unauthorized requests with 403Yes — the real boundary

For the underlying React Router patterns these guards build on, the React Router documentation (opens in a new tab) covers <Navigate> and nested routes, and the Redux Toolkit docs (opens in a new tab) cover the slice and selector APIs the auth slice uses.

What's Next

You now have a complete permission-based routing system for a React micro frontend: the backend's nested permissions tree flattened once at the auth boundary, a flat array stored in the shared Redux auth slice as a singleton, a usePermissions hook that every remote imports, a <PermissionRoute> guard that redirects before a blocked remote's bundle ever loads, a single menu config that keeps the nav bar and the guards in sync, and three layers of checks — menu, route, and component — all sitting on top of mandatory backend enforcement. This closes out the state-management section of the series. The next article — Article 29: Authentication in Micro Frontend Architecture — goes one layer deeper into where the access token and refresh token actually come from: the OTP login flow, storing the access token in the shared store, the refresh token in an HTTP-only cookie, and how every remote becomes authenticated through one shared auth boundary. Permissions ride on top of that auth foundation.

← Back to Custom Redux Hooks for Micro Frontends

Continue to Authentication in Micro Frontend Architecture →


Frequently Asked Questions

What is permission-based routing in a micro frontend?

Permission-based routing in a micro frontend is the pattern where the host application gates each remote MFE route behind a permission check before rendering it. Because every remote is mounted by a path in the host router (for example /orders, /earnings, /reports), nothing stops a user from typing a URL and loading a module they should not access. Permission-based routing solves this by storing the user's flat permission keys in the shared Redux auth slice, wrapping each remote route in a guard component like <PermissionRoute permissionKey="orders_view">, and redirecting to an allowed route when the key is missing. The guard runs before Suspense resolves the dynamic import, so a blocked user never even downloads the remote's bundle.

Where should permissions be stored in a micro frontend?

Permissions belong in the same shared Redux auth slice every remote already reads for isLoggedIn and the access token — never in a separate per-remote store. The slice is federated as a Module Federation singleton, so every remote and the host read the identical permissions array from one store instance. The backend returns permissions as a nested tree at login; the host flattens that tree into a flat array of string keys (inventory_view, orders_edit, orders_returns_approve) exactly once at the auth boundary, then dispatches the flat array with setUserInfo. Guards and menu filters then do a single permissions.includes(key) check instead of walking a tree on every render.

How do I hide menu items based on user permissions?

Drive both the navigation menu and the route guards from one menu config where each item declares the permissionKey that unlocks it. The host header filters MAIN_MENU_CONFIG with permissions.includes(item.permissionKey) so only allowed items render in the nav bar. This keeps the menu and the guards in sync because they read the same source — an item with no matching permission disappears from the menu and its route redirects. Hiding the menu item is a UX layer only; it is not security, because a user can still type the URL directly, which is why the <PermissionRoute> guard on the route is the real gate.

Is client-side permission routing secure on its own?

No. Client-side permission routing is a user-experience layer, not a security boundary. The flat permissions array, the menu filter, and the route guard all live in JavaScript the user fully controls — they can edit the Redux state in DevTools or call the API directly. Every permission check in the host and remotes MUST be mirrored by server-side authorization on every protected API endpoint, so even if a user forces a remote to render, the backend rejects the unauthorized data request with a 403. Treat the front-end permission system as the thing that gives the right user a clean experience, and the backend as the thing that actually stops the wrong user.

How does the route guard avoid loading the blocked remote's bundle?

The <PermissionRoute> guard is the parent element of the <Suspense> that wraps the lazy-loaded remote. React evaluates the guard first: when the permission key is missing it returns <Navigate replace /> and the children — including the Suspense boundary and the lazy() remote — are never rendered. Because the dynamic import('OrdersManagement/OrdersMFE') only fires when React commits the lazy component, and that never happens for a redirected route, the blocked remote's remoteEntry chunk is never fetched. This means permission routing also saves bandwidth: unauthorized users do not download MFE bundles they cannot use.

What is the difference between a route guard, a menu filter, and a component gate?

They are three layers of the same permission system applied at different scopes. The menu filter hides whole nav items the user cannot access (UX layer, not security). The route guard wraps an entire remote route and redirects when the key is missing (prevents loading the MFE — the primary front-end gate). The component gate is a check inside an already-allowed remote, like wrapping an Approve refund button in can('orders_edit') so a viewer sees the orders module but not the edit action. A complete system uses all three plus server-side enforcement: the menu for discoverability, the route guard for module access, the component gate for action-level control, and the backend as the real authority.

Why flatten the nested permissions tree instead of checking it directly?

The backend models permissions as a nested tree (modules, submenus, boolean flags) because that maps to how an admin UI edits roles. But route guards and menu filters run on every render and need a fast, simple check. Flattening the tree once at the auth boundary into a flat array of string keys turns every check into a single permissions.includes('orders_view') or a Set lookup — no recursive tree walking, no null-checking nested submenus at each call site. The flat keys also read clearly in the code (permissionKey="orders_returns_approve") and make the menu config, the guard, and the component gate all speak the same vocabulary. Flatten at the edge, check flat everywhere else.