Skip to content
<AG/>
← Blog

21 Jun 2025

Role-Based Access Control in React Router: A Data-Router RBAC Template

How to protect routes and components by role and permission scope using React Router's data-router API — the pattern behind my open-source react-data-router-template.

Most React starters handle authentication ("is there a user?") but leave authorization ("is this specific user allowed to see this route?") as an exercise for later. Later usually means ad hoc if (user.role === "admin") checks scattered across components.

I built react-data-router-template — a React 19 + TypeScript starter — around a single idea: authorization should be a property of the route, declared where the route is defined, not buried inside the page component. Here's the pattern, and the React Router primitives it's built on.

Live demo: react-data-router-template.ayanghosh.in

Routes as data, not JSX

React Router's data-router API lets you describe your route tree as an array of plain objects (RouteObject[]) instead of nesting <Route> JSX. That matters here because a plain object is easy to wrap — you can transform the Component a route points to before the router ever sees it.

const routes: RouteObject[] = [
  {
    path: "/",
    Component: Layout,
    children: [
      {
        path: "/admin",
        Component: withGuards(AdminPage, { guard: { roles: ["admin"] } }),
      },
      {
        path: "/create-post",
        Component: withGuards(CreatePostPage, {
          guard: { roles: ["user", "admin"], scopes: ["write"] },
        }),
      },
    ],
  },
];

The route table reads like a permissions manifest. You can see at a glance that /admin needs the admin role, and /create-post needs a write scope — without opening either page component.

The guard: a higher-order component

withGuards is the piece that makes this work. It wraps a page component and returns a new component that checks auth state before rendering the wrapped one:

const withGuards = <P extends object>(
  WrappedComponent: ComponentType<P> | null | undefined,
  options: GuardOptions,
) => {
  const { mode = "private", redirectPath } = options;

  const GuardedRouteComponent = (props: P) => {
    const { isAuthenticated, hasPermission } = useAuth();

    if (mode === "private") {
      if (!isAuthenticated) return <Navigate to={ROUTE_CONSTANTS.LOGIN} />;
      if (options.guard && !hasPermission(options.guard)) {
        return <Navigate to={ROUTE_CONSTANTS.FORBIDDEN} />;
      }
    }

    if (mode === "auth" && isAuthenticated) {
      return <Navigate to={redirectPath || "/"} />;
    }

    return WrappedComponent ? <WrappedComponent {...props} /> : null;
  };

  return GuardedRouteComponent;
};

Two modes fall out of the same function:

  • mode: "private" (default) — no session redirects to /login; wrong role or missing scope redirects to /forbidden.
  • mode: "auth" — the inverse, for pages like /login itself: an already-authenticated user gets redirected away instead of seeing the login form again.

Because the check happens in the HOC, every guarded route gets consistent redirect behavior for free — there's no way for a page component to forget the check, because the check isn't its job.

Roles vs. scopes

The guard accepts both roles and scopes, which map to two different questions:

  • Roles answer who are you"admin", "user". Coarse-grained, usually one or two per user.
  • Scopes answer what can you do"write", "read". Fine-grained, composable, and reusable across roles.

/create-post in the example needs roles: ["user", "admin"] and scopes: ["write"] — either role can access it, but only if their scopes include write. That combination is hard to express cleanly with roles alone, which is why most real permission systems end up with both eventually.

Where auth state lives

The guard calls useAuth(), a thin wrapper over a Zustand store with the persist middleware, so a logged-in session survives a page refresh via localStorage:

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: getCurrentUser(),
      isAuthenticated: !!getCurrentUser(),
      // login / logout / checkAuth / hasPermission ...
    }),
    { name: "auth-storage", storage: createJSONStorage(() => localStorage) },
  ),
);

hasPermission({ roles, scopes }) is the single function both the guard and any component can call to ask "is the current user allowed to do X" — one source of truth, checked at the route level and available anywhere else you need it too.

Why this shape holds up

  • Declarative, scannable routes. Anyone can read the route table and know the permission model without reading every page.
  • Impossible to forget. The check lives in the wrapper, not the page — a new protected page gets protection by construction, not by the author remembering to add a check.
  • Roles and scopes composed, not hardcoded. Adding a new permission combination is a one-line change to a route's guard option, not a new if branch somewhere in a component tree.

Try it

The template also ships Tailwind CSS, shadcn/ui, TanStack Query, React Hook Form and Zod pre-wired, so a new project starts from this structure instead of a blank Vite app. MIT licensed — clone it, swap the auth service for your backend, and the routing/RBAC scaffolding is already done.

I'm Ayan Ghosh, a full stack software engineer in Kolkata. If you use this template or have ideas to improve the guard API, open an issue on the repo — I'd like to hear it.