How to Handle Authentication and Protected Routes in React
Advertisement
🧩 Scenario
Architecture Walkthrough
The Three-State Auth Check
A protected route needs to handle three distinct states, not two. The session can be loading (async auth check in flight), unauthenticated (no session), or authenticated (session confirmed). Missing the loading state causes a flash redirect: on page refresh, the auth context starts with no session, the guard immediately redirects to login, and then the async check completes and the user finds themselves on the login page. A isLoading state gates the redirect until the auth check resolves.
The isLoading render returns a spinner or null. Only after loading is false does the guard decide to redirect or render children. This is the most common production bug with protected routes and the first thing interviewers check.
The Replace Prop and History
When the guard redirects to /login, using replace instead of a normal navigation removes the protected page from the history stack. Without it, after a successful login the user navigates forward, then presses back, and lands on /login again, which immediately redirects them back. The loop is disorienting. With replace, the login page takes the place of the protected page in history so back goes somewhere sensible.
Preserving the Attempted URL
A guard that always redirects to the default dashboard after login creates friction. The correct pattern stores the attempted URL in the Navigate location state and reads it back in the login page after authentication. This is a two-part contract: the guard writes the destination into state on redirect, and the login page reads it on success.
Key Code Explained
import { Navigate, useLocation } from 'react-router-dom';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'admin' | 'user';
}
// ProtectedRoute checks session state before rendering children
function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const { user, isLoading } = useAuth(); // reads from AuthContext
const location = useLocation();
// Phase 1: auth check is still in flight — show nothing yet
if (isLoading) return <FullPageSpinner />;
// Phase 2: no session — redirect to login, preserve intended destination
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Phase 3: authenticated but wrong role
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
// Phase 4: all checks pass
return <>{children}</>;
}
// Route config: layout route pattern avoids wrapping each route individually
function AppRouter() {
return (
<BrowserRouter>
<Routes>
{/* Public routes — no auth required */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
{/* Protected layout route — ProtectedRoute wraps all children */}
<Route element={<ProtectedRoute><Outlet /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Route>
{/* Admin-only route */}
<Route
path="/admin"
element={
<ProtectedRoute requiredRole="admin">
<AdminPanel />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
// Login page reads the intended destination from location state
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
// Fall back to /dashboard if there is no saved destination
const from = (location.state as { from?: Location })?.from?.pathname ?? '/dashboard';
const handleLogin = async (credentials: LoginCredentials) => {
await login(credentials); // updates AuthContext
navigate(from, { replace: true }); // replace login in history
};
return <LoginForm onSubmit={handleLogin} />;
}
The layout route pattern (<Route element={<ProtectedRoute><Outlet /></ProtectedRoute>}) is the clean way to protect a group of routes. The <Outlet /> renders the matched child route inside the protection wrapper. This is preferable to wrapping each <Route> element individually, which duplicates the guard across every protected page.
Tradeoffs
| Check location | First-paint behavior | Complexity | Best for |
|---|---|---|---|
| Client-side guard | Shows spinner briefly during async check | Low | SPAs with client-side auth context |
| Server-side redirect (Next.js middleware) | No flash; redirect happens before HTML is sent | Medium | Next.js apps; SSR-first projects |
| Both | Zero flash with strong server enforcement | Higher | High-security apps |
What Interviewers Actually Check
- Whether you handle the
isLoadingstate and can explain what happens if you do not - Whether you know
replaceis required and why (history loop prevention) - Whether you preserve the intended URL and redirect back to it after login
- Whether you know the layout route pattern to avoid repeating the guard
- Whether you can extend the guard to roles without changing the calling code
Follow-Up Questions
- In Next.js App Router, how would you implement protected routes using middleware compared to a client-side ProtectedRoute component?
- If the user's JWT expires mid-session (not on page load), how would you detect this and redirect to login without a hard refresh?
- How do you write a test with React Testing Library that verifies ProtectedRoute redirects an unauthenticated user to
/login? - How does
useAuthget the session to the ProtectedRoute? Describe the full data flow from session storage to component. - What is the difference between a route-level guard and a component-level auth check, and when would you use each?
Common Candidate Mistakes
- Not rendering a spinner during
isLoading— the route flashes to/loginon every page refresh even for authenticated users - Checking
localStorage.getItem('token')synchronously as the auth source — does not handle expired tokens or sessions stored in HttpOnly cookies - Omitting
replaceon the login redirect — creates a history loop where back goes to login indefinitely - Not reading
location.state.fromafter login — users always land on the default page instead of where they were trying to go - Forgetting that
useLocationmust be used inside the Router tree — calling it in a component above<BrowserRouter>throws a runtime error
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you implement a ProtectedRoute that handles loading, unauthenticated, and authenticated states?
- Can you explain why
replaceis required on the Navigate redirect? - Can you store and restore the intended URL across the login flow?
- Can you extend the guard to enforce a required role?
- Can you use a layout route to protect a group of routes with a single ProtectedRoute?
Summary
A ProtectedRoute component checks auth state before rendering its children. It has three phases: loading (show a spinner while the async auth check resolves), unauthenticated (redirect to login with the intended URL in location state and replace to prevent a history loop), and authenticated (render children, optionally checking a role). Using a layout route with <Outlet /> applies the guard to an entire group of routes without duplicating it per page. The login page reads the saved destination from location state and navigates to it after authentication so users land where they intended.
Why use replace on Navigate inside a protected route?
replace prevents the login page from being added to the browser history stack. Without it, after a successful login the back button returns to the login screen. With replace, login is swapped out of history so the back button goes to the page before the protected route.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement