Answering the Core Question
React Router v6 does not expose a native declarative field in the route object for granular RBAC. The practical pattern that developers use is to wrap the loader itself with a guard that performs authentication and permission checks before the original loader logic runs. This wrapper can be defined once and reused across all protected routes, so you avoid duplicating logic or adding extra components after the loader has already resolved.
Key Points
- Use a higher‑order loader function (e.g.,
withAuth) that accepts the original loader and a list of required permissions.
- Inside the wrapper, check the user’s authentication status and permissions before calling the original loader.
- If the check fails, return a
redirect or a Response with a 401/403 status – this halts loader execution entirely.
- Centralise permission data (role matrix, capability flags, or JWT claims) in a single module or context so that the guard can reference it without hard‑coding per route.
- Guard the login route itself so that it never triggers the same guard again, preventing infinite redirect loops when a token expires.
Confirmed Pattern (Validated by Community Guides)
import { redirect } from "react-router-dom";
import { useAuth } from "./authContext"; // central auth hook
// Permission matrix example
const PERMISSIONS = {
admin: ["dashboard", "settings"],
editor: ["dashboard"],
viewer: []
};
export function withAuth(originalLoader, required = []) {
return async ({ request, params }) => {
const { user, token } = useAuth(); // synchronous values from context
if (!token) {
// Not authenticated – redirect to login
return redirect("/login", { replace: true });
}
// Verify token is still valid (e.g., by checking expiry)
if (isTokenExpired(token)) {
// Token expired – clear session and redirect
logout();
return redirect("/login", { replace: true });
}
// Check granular permissions
const allowed = required.every(r => PERMISSIONS[user.role]?.includes(r));
if (!allowed) {
return new Response("Forbidden", { status: 403 });
}
// All checks passed – run the original loader
return originalLoader({ request, params });
};
}
Usage in the route config:
import { withAuth } from "./authGuard";
import { dashboardLoader } from "./loaders/dashboard";
export const routes = [
{
path: "/dashboard",
loader: withAuth(dashboardLoader, ["dashboard"]),
element:
},
// …other routes
];
Managing Central Permissions
- Define a single source of truth. Store role‑to‑capability mappings in a module or fetch them once from a permissions API and cache them in context.
- Use JWT claims when possible. If the backend embeds permissions in the token, parse them in the guard instead of making an extra request.
- Invalidate cache on logout or token refresh. Ensure that stale permission data can’t grant access after credentials change.
- Guard the login route. Exclude it from the
withAuth wrapper so that a redirect to /login does not re‑enter the guard, preventing loops.
Preventing Infinite Redirect Loops
- Always redirect to a route that is explicitly marked as public (e.g.,
/login or /unauthorized).
- Do not apply the same guard to those public routes.
- If a token is expired, clear it before redirecting so the guard sees an unauthenticated state on the next request.
- Optionally, add a
maxRedirects counter in the guard or rely on the browser’s redirect limit.
Missing Diagnostic Detail
To fine‑tune the guard, could you confirm whether the application stores permissions in JWT claims or retrieves them from a separate API endpoint after authentication? This choice affects whether the guard performs a quick claim check or an additional network request.