Building Reliable Protected Routes with React Router v7


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