Proper Implementation of Private Routes in React Router
A common security and UX issue in React applications is the incorrect implementation of private routes, which should protect authenticated-only content. Here’s how to properly handle authentication and route protection.
The Core Problem
Typical incorrect implementations:
// ❌ Insecure: Just hiding the link doesn't protect the route
{isLoggedIn && <Link to="/dashboard">Dashboard</Link>}
// ❌ Incomplete: Only checking on initial render
function PrivateRoute() {
const [isAuth] = useState(checkAuth());
return isAuth ? <Outlet /> : <Navigate to="/login" />;
}
Correct Implementation (React Router v6)
1. Basic Private Route Component
import { Navigate, Outlet } from 'react-router-dom';
function PrivateRoute() {
const { currentUser } = useAuth(); // Your auth context/hook
return currentUser ? <Outlet /> : <Navigate to="/login" replace />;
}
2. Usage in Route Configuration
<Routes>
<Route path="/" element={<PublicLayout />}>
<Route index element={<HomePage />} />
<Route path="login" element={<LoginPage />} />
</Route>
<Route element={<PrivateRoute />}> {/* Wrap protected routes */}
<Route path="dashboard" element={<Dashboard />} />
<Route path="profile" element={<UserProfile />} />
</Route>
</Routes>
Key Features of a Secure Private Route
- Authentication Check: Verifies user is logged in
- Redirect: Sends unauthorized users to login
- State Preservation: Remembers where user came from
- Route Protection: Guards both UI and data layers
Advanced Patterns
1. Route-Specific Permissions
function AdminRoute() {
const { currentUser } = useAuth();
if (!currentUser) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
if (!currentUser.isAdmin) {
return <Navigate to="/unauthorized" replace />;
}
return <Outlet />;
}
2. Persistent Session Check
function PrivateRoute() {
const { currentUser, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <LoadingSpinner />;
}
if (!currentUser) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <Outlet />;
}
3. Protected Data Fetching
function Dashboard() {
const { data, error } = useProtectedQuery('/api/dashboard');
if (error?.status === 401) {
return <Navigate to="/login" replace />;
}
// Render dashboard
}
Common Mistakes to Avoid
❌ Client-Side Only Protection
// ❌ Server still returns data if API is unprotected
function PrivateComponent() {
if (!user) return <Navigate to="/login" />;
return <SecretData />;
}
✅ Fix: Protect both client and server
// Client
<Route element={<PrivateRoute />}>
<Route path="secret" element={<SecretData />} />
</Route>
// Server
router.get('/api/secret', authenticate, (req, res) => {
// Return data only if authenticated
});
❌ Not Persisting Return URL
<Navigate to="/login" /> {/* ❌ Loses original location */}
✅ Fix: Preserve navigation state
<Navigate
to="/login"
replace
state={{ from: location }}
/>
❌ Blocking Render During Auth Check
function PrivateRoute() {
const { user } = useAuth(); // Synchronous check
// ❌ Fails if auth requires async verification
return user ? <Outlet /> : <Navigate to="/login" />;
}
✅ Fix: Handle loading state
function PrivateRoute() {
const { user, isLoading } = useAuth();
if (isLoading) return <LoadingScreen />;
return user ? <Outlet /> : <Navigate to="/login" />;
}
Complete Authentication Flow
// App.js
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<PrivateRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
</Route>
</Routes>
</Router>
</AuthProvider>
);
}
// PrivateRoute.js
function PrivateRoute() {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <LoadingSpinner />;
if (!user) {
return (
<Navigate
to="/login"
replace
state={{
from: location,
message: "Please log in to continue"
}}
/>
);
}
return <Outlet />;
}
// LoginPage.js
function LoginPage() {
const { login } = useAuth();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
const handleSubmit = async () => {
await login(credentials);
// After login, redirect to where user came from
navigate(from, { replace: true });
};
return (
<form onSubmit={handleSubmit}>
{/* Login form */}
</form>
);
}
Security Best Practices
- Server-Side Validation: Never trust client-side checks alone
- Secure Cookies: Use HttpOnly, SameSite, and Secure flags
- Token Expiration: Implement JWT expiration and refresh
- Rate Limiting: Protect authentication endpoints
- CSRF Protection: Especially for cookie-based auth
Key Takeaways
- Use
<PrivateRoute>
wrapper for protected route hierarchies - Preserve navigation state for post-login redirects
- Protect both UI and API layers
- Handle loading states during auth verification
- Implement proper error handling for expired sessions
Proper private route implementation is essential for both security and user experience in React applications. Following these patterns ensures authenticated users access protected content while unauthorized users are redirected appropriately.