How would you protect routes that require authentication and redirect users to the login page if they’re not authenticated
A ProtectedRoute wrapper checks auth state; if unauthenticated, redirect to /login (preserving the intended URL for post-login return). Handle the loading state while auth resolves to avoid a flash. But remember: route guards are UX — the server must still authorize every request.
Protecting routes is a wrapper component that gates rendering on auth state — with a few details that separate a working answer from a robust one.
The ProtectedRoute pattern
function ProtectedRoute({ children }) {
const { user, status } = useAuth();
const location = useLocation();
if (status === "loading") return <FullPageSpinner />; // auth not resolved yet
if (!user) {
// redirect to login, REMEMBERING where they wanted to go
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// usage
<Route path="/dashboard" element={
<ProtectedRoute><Dashboard /></ProtectedRoute>
} />After login, send them back:
const from = location.state?.from?.pathname || "/";
navigate(from, { replace: true });The details that matter
1. The loading state. Auth status is usually resolved asynchronously (a /me call or reading a cookie session). If you only check if (!user), you'll flash the login page before auth resolves, then bounce the user to the dashboard. You need a three-state model — loading | authenticated | unauthenticated — and render a spinner during loading.
2. Preserve the intended destination. Pass location in the redirect's state so after login the user lands where they were going — not always the homepage. Deep links should survive a login detour.
3. replace, not push. Use replace on the redirect so the protected URL isn't left in history (back button shouldn't bounce them).
4. Role/permission gating. Extend it: <ProtectedRoute requiredRole="admin"> redirects authenticated-but-unauthorized users to a 403 page, not login.
5. Layout-level guards. Instead of wrapping every route, wrap a shared layout route so all child routes are protected at once.
The non-negotiable
Route guards are UX, not security. They make protected pages unreachable in the UI and route users sensibly — but anyone can edit client state or call the API directly. Every protected API endpoint must authorize server-side. The guard's job is a smooth experience; the server is the actual gate.
The framing
"A ProtectedRoute wrapper that reads auth state. The details that make it robust: a three-state model — loading/authenticated/unauthenticated — so I render a spinner while auth resolves instead of flashing the login page; preserving the attempted URL in the redirect's state so post-login I send them back there; using replace so history stays clean; and a role variant for authorization. And the load-bearing caveat — this is UX, the server still authorizes every request."
Follow-up questions
- •Why do you need a loading state in the route guard?
- •How do you send the user back to the page they originally wanted?
- •Why use replace instead of push for the redirect?
- •How is route protection different from real authorization?
Common mistakes
- •No loading state — flashing the login page before auth resolves.
- •Always redirecting to the homepage, losing the intended destination.
- •Using push so the protected URL stays in history.
- •Treating the route guard as actual security.
- •Wrapping every route individually instead of a shared layout route.
Performance considerations
- •Resolving auth on every navigation can cause waterfalls — cache the auth state in context/memory so the guard is a synchronous check after the initial resolution.
Edge cases
- •Auth still resolving on first render.
- •Token expires while the user is on a protected page.
- •Deep-linking to a protected route while logged out.
- •Authenticated but lacking the required role.
Real-world examples
- •React Router ProtectedRoute / loader-based redirects.
- •Next.js middleware or layout-level auth checks redirecting to /login.