π― Implementing role-based access control (RBAC) in single-page applications can be challenging. This guide demonstrates how to build a comprehensive role-based navigation system in Remix that:
- Loads permissions configuration once at startup
- Enforces permissions on both client and server
- Tracks and validates navigation transitions
- Provides a clean API for your components
We'll start with the basics and progressively advance to a complete solution.
Project Setup
Begin by creating a Remix project. We'll build an e-commerce application with the following user roles:
CUSTOMER
SUPPORT_AGENT
STORE_MANAGER
SYSTEM_ADMIN
Each role will have access to different areas of the application.
Step 1: Define Routes and Permissions
Start by defining your routes and permissions in a JSON configuration file:
// app/config/routePermissions.json
{
"publicRoutes": ["LOGIN", "REGISTER", "ACCESS_DENIED"],
"routeConfig": {
"SYSTEM_ADMIN": ["DASHBOARD", "USER_MANAGEMENT", "PRODUCT_MANAGEMENT", "STORE_SETTINGS", "ORDERS", "REPORTS", "ACCOUNT", "LOGOUT"],
"STORE_MANAGER": ["DASHBOARD", "PRODUCT_MANAGEMENT", "STORE_SETTINGS", "ORDERS", "REPORTS", "ACCOUNT", "LOGOUT"],
"SUPPORT_AGENT": ["DASHBOARD", "CUSTOMER_SUPPORT", "ORDERS", "ACCOUNT", "LOGOUT"],
"CUSTOMER": ["DASHBOARD", "ORDERS", "WISHLIST", "ACCOUNT", "LOGOUT"]
},
"routeTransitions": {
"DASHBOARD": ["LOGIN", "USER_MANAGEMENT", "PRODUCT_MANAGEMENT", "STORE_SETTINGS", "CUSTOMER_SUPPORT", "ORDERS", "WISHLIST", "REPORTS", "ACCOUNT", "ACCESS_DENIED", "*"],
"USER_MANAGEMENT": ["DASHBOARD"],
"PRODUCT_MANAGEMENT": ["DASHBOARD", "STORE_SETTINGS"],
"STORE_SETTINGS": ["DASHBOARD", "PRODUCT_MANAGEMENT"],
"CUSTOMER_SUPPORT": ["DASHBOARD", "ORDERS"],
"ORDERS": ["DASHBOARD", "CUSTOMER_SUPPORT"],
"WISHLIST": ["DASHBOARD", "ORDERS"],
"REPORTS": ["DASHBOARD", "ORDERS", "PRODUCT_MANAGEMENT"],
"ACCOUNT": ["DASHBOARD"],
"LOGIN": ["DASHBOARD", "REGISTER"],
"REGISTER": ["LOGIN"],
"ACCESS_DENIED": ["*"],
"*": ["DASHBOARD"]
}
}
This configuration defines:
-
publicRoutes
: Routes that don't require authentication -
routeConfig
: Routes accessible by each role -
routeTransitions
: Valid navigation paths between routes
Step 2: Create Route Utilities
Next, define route utilities to be used throughout the application:
// app/utils/routeUtils.ts
export enum UserRoleEnum {
SYSTEM_ADMIN = 'SYSTEM_ADMIN',
STORE_MANAGER = 'STORE_MANAGER',
SUPPORT_AGENT = 'SUPPORT_AGENT',
CUSTOMER = 'CUSTOMER',
}
export interface RouteInfo {
key: string;
path: string;
name: string;
}
export const AppRoutes = {
LOGIN: { key: 'LOGIN', path: '/login', name: 'Login' },
REGISTER: { key: 'REGISTER', path: '/register', name: 'Register' },
ACCESS_DENIED: { key: 'ACCESS_DENIED', path: '/access-denied', name: 'Access Denied' },
DASHBOARD: { key: 'DASHBOARD', path: '/dashboard', name: 'Dashboard' },
USER_MANAGEMENT: { key: 'USER_MANAGEMENT', path: '/users', name: 'User Management' },
PRODUCT_MANAGEMENT: { key: 'PRODUCT_MANAGEMENT', path: '/products', name: 'Product Management' },
STORE_SETTINGS: { key: 'STORE_SETTINGS', path: '/settings', name: 'Store Settings' },
CUSTOMER_SUPPORT: { key: 'CUSTOMER_SUPPORT', path: '/support', name: 'Customer Support' },
ORDERS: { key: 'ORDERS', path: '/orders', name: 'Orders' },
WISHLIST: { key: 'WISHLIST', path: '/wishlist', name: 'Wishlist' },
REPORTS: { key: 'REPORTS', path: '/reports', name: 'Reports' },
ACCOUNT: { key: 'ACCOUNT', path: '/account', name: 'Account' },
LOGOUT: { key: 'LOGOUT', path: '/logout', name: 'Logout' },
} as const;
export type AppRouteKeys = keyof typeof AppRoutes;
export const getRoutePath = (route: AppRouteKeys): string => AppRoutes[route].path;
export const getRouteName = (route: AppRouteKeys): string => AppRoutes[route].name;
export const getRouteFromPath = (path: string): AppRouteKeys | undefined => {
const entry = Object.entries(AppRoutes).find(([_, info]) => info.path === path);
return entry ? (entry[0] as AppRouteKeys) : undefined;
};
export type RouteInfoType = (typeof AppRoutes)[AppRouteKeys];
export function normalizeUserRole(role: string | undefined): UserRoleEnum | undefined {
if (!role) return undefined;
return UserRoleEnum[role as keyof typeof UserRoleEnum];
}
Step 3: Create a Navigation Store
Use Zustand for navigation state management:ξ
// app/stores/navigationStore.ts
import { create } from 'zustand';
import { AppRouteKeys } from '~/utils/routeUtils';
interface NavigationState {
currentRoute: AppRouteKeys | null;
previousRoute: AppRouteKeys | null;
setRoute: (route: AppRouteKeys) => void;
}
// Define state updater type
type SetState = (fn: (state: NavigationState) => Partial<NavigationState>) => void;
export const useNavigationStore = create<NavigationState>((set: SetState) => ({
currentRoute: null,
previousRoute: null,
setRoute: (route: AppRouteKeys) =>
set((state: NavigationState) => ({
previousRoute: state.currentRoute,
currentRoute: route,
})),
}));
Thanks for pointing that out! Here's the complete dev.to
-style article including Step 6 and Step 7, maintaining every single word from your original content:
π― Role-Based Route Permissions in Remix: Step-by-Step Guide
β Step 4: Create the Route Configuration Service
This is where the magic happens. We'll create a singleton service that loads the route permissions once at startup:
// app/config/routeConfigLoader.server.ts
import { UserRoleEnum, AppRouteKeys, AppRoutes } from '~/utils/routeUtils';
import * as routePermissions from './routePermissions.json';
// Wildcard constant for route permissions
const WILDCARD = '*' as AppRouteKeys;
// Cast the imported JSON to the expected structure
const appRoutePermissions = routePermissions as unknown as {
routeConfig: Record<string, AppRouteKeys[]>;
routeTransitions: Record<string, AppRouteKeys[]>;
publicRoutes: AppRouteKeys[];
};
// Define types for our lookup tables
export type RouteTransitionMap = Record<string, Record<string, boolean>>;
export type RoleRouteMap = Record<string, Record<string, boolean>>;
export type TransitionLookupKey = `${string}:${string}:${string}`;
export type TransitionLookupMap = Record<TransitionLookupKey, boolean>;
// Singleton class to hold pre-computed route permissions
class RouteConfigService {
private static instance: RouteConfigService;
// Pre-computed lookup tables
private routeTransitionMap: RouteTransitionMap = {};
private roleRouteMap: RoleRouteMap = {};
// Cache for transition lookups - only populated on demand
private transitionLookupCache: Record<string, boolean> = {};
private publicRoutes: Set<string> = new Set();
private isInitialized = false;
private constructor() {}
static getInstance(): RouteConfigService {
if (!RouteConfigService.instance) {
RouteConfigService.instance = new RouteConfigService();
}
return RouteConfigService.instance;
}
init(): void {
if (this.isInitialized) return;
console.log('Initializing route configuration...');
// Initialize public routes
this.initializePublicRoutes();
// Initialize route transition map
this.initializeRouteTransitionMap();
// Initialize role-based route maps
this.buildRoleRouteMaps();
// Pre-compute common transition lookups
this.buildTransitionLookupMap();
this.isInitialized = true;
console.log('Route configuration initialized successfully');
}
private initializePublicRoutes(): void {
const { publicRoutes } = appRoutePermissions;
publicRoutes.forEach(route => this.publicRoutes.add(route));
}
private initializeRouteTransitionMap(): void {
const { routeTransitions } = appRoutePermissions;
// Get all route keys from both sources and destinations
const routeKeys = new Set<string>();
Object.keys(routeTransitions).forEach((key) => routeKeys.add(key));
Object.values(routeTransitions).forEach((destinations) => {
destinations.forEach((dest) => {
if (dest !== WILDCARD) routeKeys.add(dest);
});
});
const allRoutes = Array.from(routeKeys);
allRoutes.forEach((fromRoute) => {
this.routeTransitionMap[fromRoute] = {};
allRoutes.forEach((toRoute) => {
this.routeTransitionMap[fromRoute][toRoute] = false;
});
const allowedTransitions = routeTransitions[fromRoute] || [];
allowedTransitions.forEach((toRoute) => {
const routeStr = String(toRoute);
if (routeStr === '*') {
allRoutes.forEach((route) => {
this.routeTransitionMap[fromRoute][route] = true;
});
} else {
this.routeTransitionMap[fromRoute][toRoute] = true;
}
});
});
if (routeTransitions['*']) {
const wildcardDestinations = routeTransitions['*'];
allRoutes.forEach((fromRoute) => {
wildcardDestinations.forEach((toRoute) => {
const routeStr = String(toRoute);
if (routeStr !== '*') {
this.routeTransitionMap[fromRoute][toRoute] = true;
}
});
});
}
}
private buildRoleRouteMaps(): void {
const { routeConfig } = appRoutePermissions;
const routeKeys = new Set<string>();
Object.keys(routeConfig).forEach((key) => routeKeys.add(key));
Object.values(routeConfig).forEach((routes) => {
routes.forEach((route) => {
if (route !== WILDCARD) routeKeys.add(route);
});
});
const allRoutes = Array.from(routeKeys);
Object.values(UserRoleEnum).forEach((role) => {
this.roleRouteMap[role] = {};
allRoutes.forEach((route) => {
this.roleRouteMap[role][route] = false;
});
const allowedRoutes = routeConfig[role] || [];
allowedRoutes.forEach((route) => {
if (route === WILDCARD) {
allRoutes.forEach((r) => {
this.roleRouteMap[role][r] = true;
});
} else {
this.roleRouteMap[role][route] = true;
}
});
});
}
private buildTransitionLookupMap(): void {
const userRoles = Object.values(UserRoleEnum);
const commonRoutes = [AppRoutes.DASHBOARD.key, AppRoutes.LOGIN.key];
commonRoutes.forEach((fromRoute) => {
const possibleDestinations = this.routeTransitionMap[fromRoute] || {};
const toRoutes = Object.keys(possibleDestinations);
toRoutes.forEach((toRoute) => {
userRoles.slice(0, 2).forEach((userRole) => {
this.validateRouteAccess(fromRoute, toRoute, userRole);
});
});
});
}
private createLookupKey(
fromRoute: string,
toRoute: string,
userRole: UserRoleEnum
): TransitionLookupKey {
return `${fromRoute}:${toRoute}:${userRole}` as TransitionLookupKey;
}
public validateRouteAccess(
fromRoute: string,
toRoute: string,
userRole: UserRoleEnum
): boolean {
const key = this.createLookupKey(fromRoute, toRoute, userRole);
if (this.transitionLookupCache[key] !== undefined) {
return this.transitionLookupCache[key];
}
const hasRouteAccess = this.hasRouteAccess(toRoute as AppRouteKeys, userRole);
if (!hasRouteAccess) {
this.transitionLookupCache[key] = false;
return false;
}
const isTransitionAllowed = this.routeTransitionMap[fromRoute]?.[toRoute] || false;
this.transitionLookupCache[key] = isTransitionAllowed;
return this.transitionLookupCache[key];
}
isTransitionAllowed(
fromRoute: AppRouteKeys,
toRoute: AppRouteKeys,
userRole: UserRoleEnum
): boolean {
if (!this.isInitialized) {
this.init();
}
if (this.isPublicRoute(toRoute)) {
return true;
}
return this.validateRouteAccess(fromRoute, toRoute, userRole);
}
isPublicRoute(route: AppRouteKeys): boolean {
return this.publicRoutes.has(route);
}
hasRouteAccess(
route: AppRouteKeys,
userRole: UserRoleEnum
): boolean {
if (!this.isInitialized) {
this.init();
}
if (this.isPublicRoute(route)) {
return true;
}
return this.roleRouteMap[userRole]?.[route] || false;
}
}
// Export singleton instance
export const routeConfigService = RouteConfigService.getInstance();
// Initialize during server startup
routeConfigService.init();
export default routeConfigService;
This singleton service initializes once at server startup and caches permission lookups for efficiency. It provides methods to check if:
- A route is public
- A user role has access to a specific route
- A transition from one route to another is allowed
β Step 5: Create a Navigation Hook
Now let's create a hook to handle navigation with permission checks:
// app/utils/navigation.ts
import { useNavigate, useLocation } from '@remix-run/react';
import { useNavigationStore } from '~/stores/navigationStore';
import { AppRouteKeys, AppRoutes, getRouteFromPath } from './routeUtils';
export const useNavigation = () => {
const navigate = useNavigate();
const location = useLocation();
const { currentRoute, setRoute } = useNavigationStore();
const goTo = async (to: AppRouteKeys) => {
try {
// Check if we're in a browser environment
if (typeof window === 'undefined') {
console.warn('Attempted to use navigation in server environment');
return false;
}
const path = location.pathname;
const currentRouteFromPath = getRouteFromPath(path) as AppRouteKeys;
// Use the route from path or fallback to the store value
let fromRoute = currentRouteFromPath || currentRoute;
if (!fromRoute) {
console.warn(`β οΈ [Navigation] Cannot determine current route from path ${path}`);
fromRoute = 'UNKNOWN' as AppRouteKeys; // Use a fallback
} else if (currentRouteFromPath !== currentRoute) {
// If there's a mismatch, log it and update the store
console.log(
`π [Navigation] Updating route in store: path=${currentRouteFromPath}, store=${currentRoute || 'null'}`
);
}
// Always update navigation store with current route
setRoute(fromRoute);
sessionStorage.setItem('navigation_from', fromRoute);
sessionStorage.setItem('navigation_to', to);
const originalFetch = window.fetch;
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
const newInit: RequestInit = init ? { ...init } : {};
const headers = new Headers(newInit.headers || {});
// Get navigation info from sessionStorage
const navFrom = sessionStorage.getItem('navigation_from') || fromRoute;
// Set navigation headers for server-side validation
headers.set('X-Client-Navigation', 'true');
headers.set('X-Navigation-From', navFrom);
headers.set('X-Navigation-To', to);
newInit.headers = headers;
return originalFetch(input, newInit);
};
setTimeout(() => {
window.fetch = originalFetch;
}, 2000); // Delay restoration to allow Remix navigation to complete
// Execute the navigation
const targetPath = AppRoutes[to].path;
// Update route in store
setRoute(to);
// Perform navigation
navigate(targetPath);
// Clear navigation state after successful navigation
setTimeout(() => {
sessionStorage.removeItem('navigation_from');
}, 200);
return true;
} catch (error) {
console.error('Navigation error:', error);
return false;
}
};
return { goTo };
};
/**
* Helper function to determine the route key from a URL path
*/
export function getRouteKeyFromPath(path: string): AppRouteKeys | null {
return getRouteFromPath(path) as AppRouteKeys | null;
}
β Step 6: Create a Secure Link Component
This component enhances <Link>
by integrating our navigation tracking and route config. It prevents default navigation and relies on the goTo()
function from our useNavigation()
utility, which logs and manages transitions.
app/components/SecureLink.tsx
import React from 'react';
import { AppRouteKeys, AppRoutes } from '~/utils/routeUtils';
import { Link } from '@remix-run/react';
import { useNavigation } from '~/utils/navigation';
import { useNavigationStore } from '~/stores/navigationStore';
type SecureLinkProps = {
to: AppRouteKeys;
className?: string;
children: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
};
/**
* A secure link that uses our custom navigation logic.
* All permission checks happen server-side via middleware.
*/
export function SecureLink({ to, className, children, onClick }: SecureLinkProps) {
const { goTo } = useNavigation();
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const { currentRoute } = useNavigationStore.getState();
console.log(`π [SecureLink] Navigating from ${currentRoute || 'UNKNOWN'} to ${to}`);
if (!currentRoute) {
console.warn('β οΈ No currentRoute in navigation store. This might lead to incorrect navigation tracking.');
}
goTo(to); // handles history + header metadata
if (onClick) {
onClick(e);
}
};
const targetPath = AppRoutes[to].path;
return (
<Link
to={targetPath}
className={className}
onClick={handleClick}
data-testid={`secure-link-${to}`}
data-route-key={to}
preventScrollReset={false}
>
{children}
</Link>
);
}
β Step 7: Create a Navigation Middleware
This middleware sits server-side and intercepts navigations. It enforces rules based on user roles, allowed transitions, and route access using your route config.
app/middleware/navigationMiddleware.server.ts
import { redirect } from '@remix-run/node';
import type { Session } from '@remix-run/node';
import { UserRoleEnum, AppRouteKeys, getRouteFromPath } from '~/utils/routeUtils';
import routeConfigService from '~/config/routeConfigLoader.server';
interface NavigationContext {
request: Request;
session: Session;
}
/**
* Validates navigation intent against route permissions and allowed transitions.
*/
export async function processNavigation({ request, session }: NavigationContext): Promise<Response | null> {
const isClientNavigation = request.headers.get('X-Client-Navigation') === 'true';
const url = new URL(request.url);
const path = url.pathname;
const toRoute = getRouteFromPath(path) as AppRouteKeys;
if (!toRoute) {
return redirect('/access-denied');
}
if (routeConfigService.isPublicRoute(toRoute)) {
return null;
}
const user = session.get('user');
if (!user) {
return redirect('/login');
}
const userRole = UserRoleEnum[user.role as keyof typeof UserRoleEnum];
if (!userRole) {
return redirect('/access-denied');
}
const hasAccess = routeConfigService.hasRouteAccess(toRoute, userRole);
if (!hasAccess) {
return redirect('/access-denied');
}
if (isClientNavigation) {
const fromRoute = request.headers.get('X-Navigation-From') as AppRouteKeys;
if (fromRoute) {
const isTransitionAllowed = routeConfigService.isTransitionAllowed(fromRoute, toRoute, userRole);
if (!isTransitionAllowed) {
return redirect('/access-denied');
}
}
}
return null;
}
π§ Step 8: Create a Root Loader with Middleware
Letβs connect our middleware logic into the Remix root loader. This helps us process authentication and navigation before the app renders anything.
// app/root.tsx
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from '@remix-run/react';
import { getSession, commitSession } from './sessions.server';
import { processNavigation } from './middleware/navigationMiddleware.server';
import { AppRouteKeys, getRouteFromPath } from './utils/routeUtils';
import { useEffect } from 'react';
import { useNavigationStore } from './stores/navigationStore';
export const meta: MetaFunction = () => {
return [
{ title: 'E-Commerce Platform' },
{ name: 'description', content: 'Welcome to our e-commerce platform!' },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const navigationResult = await processNavigation({ request, session });
if (navigationResult) return navigationResult;
const url = new URL(request.url);
const currentRoute = getRouteFromPath(url.pathname) as AppRouteKeys;
return json({
currentRoute,
user: session.get('user'),
}, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
export default function App() {
const { currentRoute, user } = useLoaderData<typeof loader>();
const { setRoute } = useNavigationStore();
useEffect(() => {
if (currentRoute) {
setRoute(currentRoute);
}
}, [currentRoute, setRoute]);
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
π‘ Why this matters: By centralizing route and session logic in the root loader, we simplify access control and route tracking across the app.
π Step 9: Create Session Utilities
Next up, letβs create reusable helpers to manage our session state using cookies.
// app/sessions.server.ts
import { createCookieSessionStorage } from '@remix-run/node';
const sessionStorage = createCookieSessionStorage({
cookie: {
name: 'app_session',
secure: process.env.NODE_ENV === 'production',
secrets: ['s3cr3t1'], // Replace with your secret
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
},
});
export async function getSession(cookieHeader: string | null) {
return sessionStorage.getSession(cookieHeader);
}
export async function commitSession(session: any) {
return sessionStorage.commitSession(session);
}
export async function destroySession(session: any) {
return sessionStorage.destroySession(session);
}
β Pro Tip: Store the session secret in an environment variable in production for security.
π§± Step 10: Create a Layout Component
Letβs wrap your application with a consistent layout that includes navigation based on user roles.
// app/components/MainLayout.tsx
import React from 'react';
import { SecureLink } from './SecureLink';
import { UserRoleEnum } from '~/utils/routeUtils';
import { useLoaderData } from '@remix-run/react';
type MainLayoutProps = {
children: React.ReactNode;
};
export default function MainLayout({ children }: MainLayoutProps) {
const { user } = useLoaderData<{ user: { name: string, role: string } }>();
const getNavLinks = () => {
const role = user?.role as UserRoleEnum;
const commonLinks = [
{ to: 'DASHBOARD', label: 'Dashboard' },
{ to: 'ACCOUNT', label: 'My Account' },
{ to: 'LOGOUT', label: 'Logout' },
];
const roleSpecificLinks = {
[UserRoleEnum.SYSTEM_ADMIN]: [
{ to: 'USER_MANAGEMENT', label: 'Users' },
{ to: 'PRODUCT_MANAGEMENT', label: 'Products' },
{ to: 'STORE_SETTINGS', label: 'Settings' },
{ to: 'REPORTS', label: 'Reports' },
],
[UserRoleEnum.STORE_MANAGER]: [
{ to: 'PRODUCT_MANAGEMENT', label: 'Products' },
{ to: 'STORE_SETTINGS', label: 'Settings' },
{ to: 'ORDERS', label: 'Orders' },
{ to: 'REPORTS', label: 'Reports' },
],
[UserRoleEnum.SUPPORT_AGENT]: [
{ to: 'CUSTOMER_SUPPORT', label: 'Support' },
{ to: 'ORDERS', label: 'Orders' },
],
[UserRoleEnum.CUSTOMER]: [
{ to: 'ORDERS', label: 'My Orders' },
{ to: 'WISHLIST', label: 'Wishlist' },
],
};
return [...(roleSpecificLinks[role] || []), ...commonLinks];
};
const navLinks = getNavLinks();
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<span className="text-lg font-bold">E-Commerce Platform</span>
</div>
<nav className="ml-6 flex space-x-8">
{navLinks.map((link) => (
<SecureLink
key={link.to}
to={link.to as any}
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-700 hover:text-blue-600"
>
{link.label}
</SecureLink>
))}
</nav>
</div>
<div className="flex items-center">
{user && (
<div className="text-sm font-medium text-gray-700">
Welcome, {user.name} ({user.role})
</div>
)}
</div>
</div>
</div>
</header>
<main className="flex-grow">
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{children}
</div>
</main>
<footer className="bg-white shadow">
<div className="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<p className="text-center text-sm text-gray-500">
Β© 2025 E-Commerce Platform. All rights reserved.
</p>
</div>
</footer>
</div>
);
}
β Step 11: Create Route Middleware
// app/middleware/route-middleware.server.ts
import { redirect } from '@remix-run/node';
import type { Session } from '@remix-run/node';
import { AppRouteKeys, UserRoleEnum } from '~/utils/routeUtils';
import routeConfigService from '~/config/routeConfigLoader.server';
export async function requireUser(request: Request, session: Session) {
const user = session.get('user');
if (!user) {
// Redirect to login if no user
throw redirect('/login');
}
return user;
}
export async function requireRole(
request: Request,
session: Session,
allowedRoles: UserRoleEnum[]
) {
const user = await requireUser(request, session);
const userRole = UserRoleEnum[user.role as keyof typeof UserRoleEnum];
if (!userRole || !allowedRoles.includes(userRole)) {
throw redirect('/access-denied');
}
return { user, role: userRole };
}
export async function validateRouteAccess(
request: Request,
session: Session,
routeKey: AppRouteKeys
) {
const user = await requireUser(request, session);
const userRole = UserRoleEnum[user.role as keyof typeof UserRoleEnum];
if (!userRole) {
throw redirect('/access-denied');
}
const hasAccess = routeConfigService.hasRouteAccess(routeKey, userRole);
if (!hasAccess) {
throw redirect('/access-denied');
}
return { user, role: userRole };
}
β Step 12: Implement Route Files
Login Route
// app/routes/login.tsx
import { useActionData, Form } from '@remix-run/react';
import { redirect, json } from '@remix-run/node';
import type { ActionFunctionArgs } from '@remix-run/node';
import { getSession, commitSession } from '~/sessions.server';
import { UserRoleEnum } from '~/utils/routeUtils';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
let user = null;
if (email === 'admin@example.com' && password === 'password') {
user = {
id: '1',
name: 'Admin User',
email: 'admin@example.com',
role: 'SYSTEM_ADMIN',
};
} else if (email === 'manager@example.com' && password === 'password') {
user = {
id: '2',
name: 'Store Manager',
email: 'manager@example.com',
role: 'STORE_MANAGER',
};
} else if (email === 'support@example.com' && password === 'password') {
user = {
id: '3',
name: 'Support Agent',
email: 'support@example.com',
role: 'SUPPORT_AGENT',
};
} else if (email === 'customer@example.com' && password === 'password') {
user = {
id: '4',
name: 'Customer User',
email: 'customer@example.com',
role: 'CUSTOMER',
};
}
if (!user) {
return json({
error: 'Invalid email or password',
});
}
const session = await getSession(request.headers.get('Cookie'));
session.set('user', user);
return redirect('/dashboard', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
export default function Login() {
const actionData = useActionData<typeof action>();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white shadow rounded-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<Form method="post" className="mt-8 space-y-6">
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
{actionData?.error && (
<div className="text-red-500 text-sm">{actionData.error}</div>
)}
<div>
<div className="text-sm text-gray-600 mb-4">
Available demo accounts:
<ul className="list-disc pl-5 mt-2">
<li>admin@example.com / password</li>
<li>manager@example.com / password</li>
<li>support@example.com / password</li>
<li>customer@example.com / password</li>
</ul>
</div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Sign in
</button>
</div>
</Form>
</div>
</div>
);
}
β Step 13: Access Denied Route
File: app/routes/access-denied.tsx
// app/routes/access-denied.tsx
export default function AccessDenied() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full text-center">
<h1 className="text-4xl font-bold text-red-600">Access Denied</h1>
<p className="mt-4 text-gray-600">
You do not have permission to access this page.
</p>
<a
href="/dashboard"
className="mt-6 inline-block text-blue-600 hover:underline"
>
Go to Dashboard
</a>
</div>
</div>
);
}
}
β Step 14: Logout Route
File: app/routes/logout.tsx
// app/routes/logout.tsx
import { redirect } from '@remix-run/node';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { getSession, destroySession } from '~/sessions.server';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
},
});
}
Embracing Security Through Structure: A Complete RBAC Solution
The Journey Complete
We've built a comprehensive role-based navigation system for a Remix application that:
- Loads permission configuration just once at server startup
- Enforces permissions on both the client and server
- Validates navigation transitions to prevent unauthorized access
- Provides a clean, type-safe API for components
Advantages That Scale
This approach delivers multiple benefits that extend beyond basic access control:
- Performance: We precompute and cache permission lookups for efficiency, eliminating redundant calculations
- Security: We enforce permissions on both client and server sides, creating a dual layer of protection
- Maintainability: The permission system is centralized in a JSON file, making updates straightforward
- Type Safety: TypeScript ensures we use valid routes everywhere, preventing navigation errors
Architecture That Empowers
The complete solution provides a robust foundation:
- A type-safe route registry that keeps your application navigation in sync
- A singleton service that loads permissions configuration once, optimizing memory usage
- A navigation middleware for server-side enforcement, preventing backdoor access
- Custom link components for client-side navigation, maintaining consistent UX
- A Zustand store for tracking navigation state with minimal overhead
Beyond The Horizon
This architecture can scale to handle complex permission scenarios while keeping the API simple for developers. You can easily extend it to support more granular permissions, nested routes, or dynamic permissions based on data access.
By implementing this pattern, you've created not just a security layer, but a framework that makes your application more robust, maintainable, and user-focused. Your users will navigate confidently within their permitted boundaries, while you rest assured that your application's authorization model stands strong against unauthorized access attempts.
The journey to secure, role-based navigation is completeβbut the possibilities for extending this foundation are just beginning.
Top comments (0)