Some pages in the MERN Blog โ creating a post, editing a post, viewing the dashboard โ should only be accessible to authenticated users. If an unauthenticated user tries to visit /posts/new they should be redirected to the login page, not shown an error or a blank page. This is a protected route, also called a navigation guard. React Router does not include this feature out of the box โ you build it as a wrapper component that checks the auth state and either renders the protected content or redirects. In this lesson you will build a reusable ProtectedRoute component that handles all edge cases correctly, including the loading state while auth status is being verified.
The ProtectedRoute Pattern
Request: User navigates to /posts/new
ProtectedRoute checks auth state:
โโโ Loading (checking token/session)
โ โโโ Show spinner โ do not redirect yet (would flash login page)
โโโ Authenticated (user is logged in)
โ โโโ Render the protected page (CreatePostPage)
โโโ Not authenticated
โโโ Redirect to /login
โโโ Pass the original URL as state so login can redirect back
navigate('/login', { state: { from: location.pathname } }). After successful login, read this state and redirect the user back to where they originally wanted to go: navigate(location.state?.from || '/dashboard', { replace: true }). This creates a seamless “redirect after login” experience.Building the ProtectedRoute Component
// src/components/auth/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/context/AuthContext'; // covered in Chapter 21
import Spinner from '@/components/ui/Spinner';
function ProtectedRoute({ children, requiredRole }) {
const { user, loading } = useAuth();
const location = useLocation();
// While auth state is loading โ show spinner, do not redirect yet
if (loading) {
return <Spinner message="Checking authentication..." />;
}
// Not authenticated โ redirect to login with the original URL in state
if (!user) {
return (
<Navigate
to="/login"
state={{ from: location.pathname }}
replace // replace prevents the /login entry from appearing in history
/>
);
}
// Optional role check โ redirect if user lacks required role
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/dashboard" replace />;
}
// Authenticated (and has role if required) โ render the protected content
return children;
}
export default ProtectedRoute;
Using ProtectedRoute in App.jsx
// src/App.jsx
import ProtectedRoute from '@/components/auth/ProtectedRoute';
function App() {
return (
<PageLayout>
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/posts/:id" element={<PostDetailPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected โ any authenticated user */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/posts/new"
element={
<ProtectedRoute>
<CreatePostPage />
</ProtectedRoute>
}
/>
<Route
path="/posts/:id/edit"
element={
<ProtectedRoute>
<EditPostPage />
</ProtectedRoute>
}
/>
{/* Admin-only route */}
<Route
path="/admin"
element={
<ProtectedRoute requiredRole="admin">
<AdminPage />
</ProtectedRoute>
}
/>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</PageLayout>
);
}
Redirect Back After Login
// src/pages/LoginPage.jsx
import { useNavigate, useLocation } from 'react-router-dom';
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
// Where to go after login โ default to /dashboard
const from = location.state?.from || '/dashboard';
const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(formData.email, formData.password);
// Replace the history entry so the user cannot go "back" to the login page
navigate(from, { replace: true });
} catch (err) {
setError(err.message);
}
};
return (
<div className="login-page">
<h1>Log In</h1>
{location.state?.from && (
<p className="login-page__notice">
Please log in to access {location.state.from}
</p>
)}
<form onSubmit={handleSubmit}>...</form>
</div>
);
}
Guest-Only Routes โ Redirect if Already Logged In
// Prevent authenticated users from visiting /login or /register
function GuestRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (user) return <Navigate to="/dashboard" replace />;
return children;
}
// Usage
<Route path="/login" element={<GuestRoute><LoginPage /></GuestRoute>} />
<Route path="/register" element={<GuestRoute><RegisterPage /></GuestRoute>} />
Common Mistakes
Mistake 1 โ Not handling the auth loading state
โ Wrong โ immediately redirecting before auth is checked:
function ProtectedRoute({ children }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
// If auth is still loading, user is null โ always redirects on first load!
return children;
}
โ Correct โ wait for loading to complete:
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <Spinner />; // โ wait โ do not redirect yet
if (!user) return <Navigate to="/login" replace />;
return children;
}
Mistake 2 โ Not using replace on login redirect
โ Wrong โ login page remains in browser history:
navigate('/dashboard'); // user can press Back and return to login page
โ Correct โ replace the history entry:
navigate('/dashboard', { replace: true }); // โ Back button skips login page
Mistake 3 โ Relying only on frontend protection for sensitive data
โ Wrong โ Express API routes with no authentication middleware:
router.delete('/api/posts/:id', deletePost); // no protect middleware!
โ Correct โ every protected API route must verify the JWT server-side:
router.delete('/api/posts/:id', protect, deletePost); // โ JWT required
Quick Reference
| Pattern | Code |
|---|---|
| Basic protected route | <ProtectedRoute><Page /></ProtectedRoute> |
| Role-restricted route | <ProtectedRoute requiredRole="admin"><AdminPage /></ProtectedRoute> |
| Render-time redirect | <Navigate to="/login" state={{ from: location.pathname }} replace /> |
| Read redirect target after login | location.state?.from || '/dashboard' |
| Navigate after login (no back) | navigate(from, { replace: true }) |
| Guest route (auth โ redirect) | Check user โ <Navigate to="/dashboard" /> |