Building Reliable Protected Routes with React Router v7

Why I Needed 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 , 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; logout: () => void; } const AuthContext = createContext(undefined); export const AuthProvider: React.FC = ({ children }) => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [userRole, setUserRole] = useState(); 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 ( {children} ); }; // 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 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 Loading authentication status…; } // If logged in, render child routes; otherwise redirect to /login return isAuthenticated ? ( ) : ( ); }; Wrapping Routes with the Bouncer In your main router file (e.g. App.tsx), group all private pages under one . 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 ( {/* Protected “VIP” routes */} ); } 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 ( ); } 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; } export const PrivateRoute: React.FC = ({ allowedRoles }) => { const { isAuthenticated, isLoading, userRole } = useAuth(); const location = useLocation(); if (isLoading) return Loading…; if (!isAuthenticated) { return ; } // If roles are provided, check them if (allowedRoles && !allowedRoles.includes(userRole!)) { // Could show a “403

Jun 8, 2025 - 12:40
 0
Building Reliable Protected Routes with React Router v7

Why I Needed 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 , 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 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 }>. 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 , 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