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 unauthenticated 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

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 unauthenticated 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