Protect a route with a RouteGuard component

The intent

Restricting access to specific areas of an application is a common requirement in most modern web applications. In this article, we will build a reusable RouteGuard component that acts as a wrapper around the Route component from react-router, allowing us to protect routes from unauthorized access in a clean and declarative way.

The goal is to centralize access-control logic while keeping route definitions simple and readable.


Create your basic routes

First, bootstrap your application using the tool of your choice. For a quick setup, I recommend Vite. Alternatively, you can use CodeSandbox, which provides an excellent online development environment.

Create a components folder in your project root and add a few basic components that will later be mapped to application routes.

Home Component:

// ./components/Home.js
import React from 'react';
import { Link } from 'react-router-dom';

const Home = () => <Link to="/protected">Go to protected area</Link>;
export default Home;

Login component:

// ./components/Login.js
import React from 'react';

const Login = () => <h2>Please login</h2>;
export default Login;

Protected component:

Create another component named Protected. This component represents a section of the application that should only be accessible to authorized users.

// ./components/Protected.js
import React from 'react';

const Protected = () => <h1>Hello, I am the protected component </h1>;
export default Protected;

Now, update your main entry file (src/index.{js,tsx}) to define the initial routes.

import React from 'react';
import { render } from 'react-dom';
import { Route, BrowserRouter as Router, Switch } from 'react-router-dom';

// basic components
import Home from './components/Home';
import Login from './components/Login';

render(
  <Router>
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>
      <Route exact path="/user/login">
        <Login />
      </Route>
    </Switch>
  </Router>,
  document.getElementById('root')
);

At this point, you should be able to navigate to / and /user/login and see the corresponding components rendered correctly.


Create a Mock Access Service

Next, create a simple service to simulate user access control.

Inside your project root, create a services folder and add a UserService. This service will mock authentication logic by returning a random boolean value. This approach is not mandatory and is used only to keep the example focused and self-contained.

class UserService {
  // passing a force param that will be returned after the timeout
  checkAccess = async (force) => {
    // check / revalidate / refresh a token, execute a request... everything you want
    return new Promise((resolve) => {
      // create a random boolean value to simulate the result of your logic
      const rand = !Math.round(Math.random());
      setTimeout(() => {
        resolve(force ? force : rand);
      }, 1500);
    });
  };
}

export default new UserService();

Create the RouteGuard Component

The RouteGuard component wraps Route and determines whether the target component should be rendered or the user should be redirected.

import React, { useEffect, useState, useCallback } from 'react';
import { Navigate, Outlet, useLocation } from 'react-router-dom';

type AuthorizeFn = () => boolean | Promise<boolean>;

type RedirectTo = string | ((isAuthorized: boolean) => void);

interface RouteGuardProps {
  /**
   * Function responsible for determining authorization.
   * Can be sync or async.
   */
  authorize: AuthorizeFn;

  /**
   * Redirect path or callback when unauthorized.
   */
  redirectTo: RedirectTo;

  /**
   * Optional loader toggle.
   */
  showLoader?: boolean;

  /**
   * Optional query string appended to redirect.
   */
  query?: string;

  /**
   * Optional test id wrapper.
   */
  testId?: string;

  /**
   * Optional inline element.
   * If omitted, <Outlet /> is rendered.
   */
  element?: React.ReactElement;
}

const RouteGuard: React.FC<RouteGuardProps> = ({
  authorize,
  redirectTo,
  showLoader = true,
  query = '',
  testId,
  element,
}) => {
  const location = useLocation();
  const [isAuthorized, setIsAuthorized] = useState(false);
  const [validationCompleted, setValidationCompleted] = useState(false);

  useEffect(() => {
    let isMounted = true;

    const runAuthorization = async (): Promise<void> => {
      try {
        const result = await authorize();
        if (isMounted) {
          setIsAuthorized(Boolean(result));
          setValidationCompleted(true);
        }
      } catch {
        if (isMounted) {
          setIsAuthorized(false);
          setValidationCompleted(true);
        }
      }
    };

    runAuthorization();

    return () => {
      isMounted = false;
    };
  }, [authorize]);

  const handleRedirect = useCallback((): JSX.Element | null => {
    if (typeof redirectTo === 'function') {
      redirectTo(isAuthorized);
      return null;
    }

    return (
      <Navigate
        replace
        to={{
          pathname: redirectTo,
          search: query,
        }}
        state={{ from: location }}
      />
    );
  }, [redirectTo, isAuthorized, query, location]);

  if (!validationCompleted) {
    return showLoader ? <span>Loading...</span> : null;
  }

  if (!isAuthorized) {
    return handleRedirect();
  }

  const content = element ?? <Outlet />;

  return testId ? <div data-testid={testId}>{content}</div> : content;
};

export default RouteGuard;

Update your routes

Now, integrate the RouteGuard component into your routing configuration.

When a user navigates to the protected route, the access check is performed before rendering the component.

import { BrowserRouter as Router, Routes, Route } from 'react-router';
import RouteGuard from './RouteGuard';
import UserService from './services/UserService';
import Home from './Home';
import Login from './Login';
import Protected from './Protected';

export default function AppRouter() {
  return (
    <Router>
      <Routes>
        {/* Public routes */}
        <Route path="/" element={<Home />} />
        <Route path="/user/login" element={<Login />} />

        {/* Protected routes */}
        <Route
          element={
            <RouteGuard
              authorize={() => UserService.checkAccess()}
              redirectTo="/user/login"
              testId="protected-route"
              query="?param=1&param=2"

              // callback alternative
              // redirectTo={(authorized) =>
              //   console.log(authorized ? 'ok nice' : 'Sorry, who are you?')
              // }
            />
          }
        >
          {/* render in the <Outlet /> */}
          <Route path="/protected" element={<Protected />} />
        </Route>

        {/* Inline Guard (single route) */}
        {/* 
        <Route
          path="/protected"
          element={
            <RouteGuard
              authorize={() => UserService.checkAccess()}
              redirectTo="/user/login"
              testId="protected-route"
              element={<Protected />}
            />
          }
        /> 
        */}
      </Routes>
    </Router>
  );
}