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/loginitself: 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
guardoption, not a newifbranch somewhere in a component tree.
Try it
- Live demo: react-data-router-template.ayanghosh.in
- Repo: github.com/typescript-any/react-data-router-template
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.