DEV Community

Prince Tomar
Prince Tomar

Posted on

1

Role-Based Route Permissions in Remix / React Router v7

🎯 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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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,
    })),
}));
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

βœ… 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

βœ… 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;
}
Enter fullscreen mode Exit fullscreen mode

🧭 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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);
}
Enter fullscreen mode Exit fullscreen mode

βœ… 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

βœ… 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 };
}
Enter fullscreen mode Exit fullscreen mode

βœ… 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

βœ… 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>
  );
}
}
Enter fullscreen mode Exit fullscreen mode

βœ… 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),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Loads permission configuration just once at server startup
  2. Enforces permissions on both the client and server
  3. Validates navigation transitions to prevent unauthorized access
  4. 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)