Building Reliable Protected Routes with React Router v7

Matvey RomanovMatvey Romanov
4 min read

Why You Need This

Imagine your site is a hip nightclub. The main doors are open to all, but there’s a VIP area guarded by a bouncer: you need a secret pass (token) to get in. That bouncer on the front end is exactly what Protected Routes are for—keeping un­authenticated users out of private pages.

React Router v6 finally gave us the tools (like <Outlet />, <Navigate /> and nested routes) to build this without hacky workarounds.

Setting Up My AuthContext

First, I created an AuthContext with React’s Context API to hold:

isAuthenticated: whether the user is logged in
isLoading: whether we’re still checking their token
userRole: optional, for role-based guards
login/logout functions

This is like having a shared pizza fund: any component can peek in and see if there’s enough dough (credentials) to grab a slice (access)!

// AuthContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';

interface AuthContextType {
  isAuthenticated: boolean;
  isLoading: boolean;
  userRole?: 'admin' | 'user';
  login: () => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const [userRole, setUserRole] = useState<'admin' | 'user'>();

  useEffect(() => {
    // On mount, check token validity with server
    async function checkAuth() {
      try {
        // pretend fetch to validate token
        const res = await fetch('/api/auth/validate');
        const data = await res.json();
        setIsAuthenticated(data.ok);
        setUserRole(data.role);
      } catch {
        setIsAuthenticated(false);
      } finally {
        setIsLoading(false);
      }
    }
    checkAuth();
  }, []);

  const login = async () => {
    // call login API, then:
    setIsAuthenticated(true);
    setUserRole('user');
  };

  const logout = () => {
    // clear token, etc.
    setIsAuthenticated(false);
    setUserRole(undefined);
  };

  return (
    <AuthContext.Provider value={{ isAuthenticated, isLoading, userRole, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

// Custom hook for easy access
export const useAuth = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be inside AuthProvider');
  return ctx;
};

My “Digital Bouncer”: the PrivateRoute Component

This component checks auth status, shows a loader while we wait, then either renders the protected content via <Outlet /> or redirects to /login, carrying along where we came from.

// PrivateRoute.tsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';

export const PrivateRoute: React.FC = () => {
  const { isAuthenticated, isLoading } = useAuth();
  const location = useLocation();

  if (isLoading) {
    // Still verifying token—show a spinner or message
    return <div>Loading authentication status…</div>;
  }

  // If logged in, render child routes; otherwise redirect to /login
  return isAuthenticated ? (
    <Outlet />
  ) : (
    <Navigate
      to="/login"
      replace
      state={{ from: location }} // remember original page
    />
  );
};

Wrapping Routes with the Bouncer

In your main router file (e.g. App.tsx), group all private pages under one <Route element={<PrivateRoute />}>. It’s like fencing off the VIP area in one go:

// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './AuthContext';
import { PrivateRoute } from './PrivateRoute';
import Home from './Home';
import Login from './Login';
import Dashboard from './Dashboard';
import Profile from './Profile';

function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Home />} />

          {/* Protected “VIP” routes */}
          <Route element={<PrivateRoute />}>
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/profile" element={<Profile />} />
          </Route>

          <Route path="/login" element={<Login />} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

export default App;

Speeding Things Up with Lazy Loading

To keep our main bundle slim, I wrapped my private pages in React.lazy and , so they load only when someone actually goes looking for them—like serving dishes only when ordered:

// LazyRoutes.tsx
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { PrivateRoute } from './PrivateRoute';
const Dashboard = lazy(() => import('./Dashboard'));
const Profile   = lazy(() => import('./Profile'));
const Login     = lazy(() => import('./Login'));

export default function LazyRoutes() {
  return (
    <Suspense fallback={<div>Loading module…</div>}>
      <Routes>
        <Route path="/login" element={<Login />} />

        <Route element={<PrivateRoute />}>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/profile" element={<Profile />} />
        </Route>
      </Routes>
    </Suspense>
  );
}

Bonus: Role-Based Gates and Memory

If you need role checks, add an allowedRoles prop to PrivateRoute:

// Extended PrivateRoute with roles
interface PrivateRouteProps {
  allowedRoles?: Array<'admin' | 'user'>;
}

export const PrivateRoute: React.FC<PrivateRouteProps> = ({ allowedRoles }) => {
  const { isAuthenticated, isLoading, userRole } = useAuth();
  const location = useLocation();

  if (isLoading) return <div>Loading…</div>;

  if (!isAuthenticated) {
    return <Navigate to="/login" replace state={{ from: location }} />;
  }

  // If roles are provided, check them
  if (allowedRoles && !allowedRoles.includes(userRole!)) {
    // Could show a “403 Forbidden” page instead
    return <Navigate to="/unauthorized" replace />;
  }

  return <Outlet />;
};

And thanks to state.from in <Navigate />, after a successful login you can send the user right back where they came from—like bookmarking their spot in the club.

What I Took Away from This

Centralized & DRY: One context + one route guard—no copy-paste checks.
Clear analogies: Bouncer, VIP, pizza fund—keeps concepts memorable.
Performance: Lazy loading private modules keeps initial load quick.
Flexibility: Easy to layer in roles, custom redirects, and more.

Give your feedback and follow my Github

0
Subscribe to my newsletter

Read articles from Matvey Romanov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Matvey Romanov
Matvey Romanov

I'm a professional FullStack self-taught developer from Moscow. I truly love web-development and all that it concerns. Making websites is awesome. Follow me if you need some help