Protected Routes & Route Guards
Securing Your React App
LinkedIn Hook
Most React developers can build routes. But when an interviewer asks "how do you prevent unauthenticated users from accessing the dashboard?" — they freeze.
Protected routes are not optional in real applications. Every SaaS dashboard, admin panel, user profile, and checkout flow needs route guards. Yet most tutorials skip this entirely or show a half-baked solution that breaks the moment you add role-based access or a loading state.
In interviews, they will hand you a scenario: "Build a protected route component that redirects unauthenticated users to login, remembers where they were trying to go, shows a loading spinner while checking auth, and restricts admin pages to admin users only." They want to see if you understand the full authentication flow in a React Router app.
In this lesson, I break down every piece: the ProtectedRoute wrapper component, redirecting to login with the intended destination saved, handling the loading state while authentication is being verified, and implementing role-based access control that scales.
If you have ever wrapped a route in a clumsy
isLoggedIn ? <Dashboard /> : <Navigate to="/login" />inline check and called it a day — this lesson will show you the production-grade approach.Read the full lesson -> [link]
#React #JavaScript #InterviewPrep #Frontend #ReactRouter #Authentication #WebDevelopment #CodingInterview #100DaysOfCode
What You'll Learn
- How to build a reusable ProtectedRoute component that wraps private pages
- How to redirect unauthenticated users to login and remember their intended destination
- How to handle the loading state while authentication status is being checked
- How to implement role-based access control for admin and user-level routes
- How to structure the full authentication flow from login to protected content
The Concept — Guard the Door, Not Every Room
Analogy: The Hotel Key Card System
Imagine a hotel. When you walk through the main entrance, anyone can enter the lobby — it is public. But the moment you try to take the elevator to the guest floors, you need to tap your key card. If you do not have one, security redirects you to the front desk to check in.
That key card check at the elevator is a protected route. It sits between the public area and the private floors. You do not put a guard inside every single hotel room — that would be absurd. Instead, you put one checkpoint at the entrance to the restricted area, and everyone who passes through it is verified.
Now, imagine you arrive at the hotel, try to go directly to the rooftop bar on floor 20, but you have not checked in yet. The elevator guard does not just send you to the lobby and forget about you. He hands you a note: "After you check in, come back — you wanted floor 20." That is storing the intended destination. After you get your key card at the front desk, the concierge says "you wanted the rooftop bar, right? Here you go." You land exactly where you originally intended.
What about role-based access? Some floors are VIP-only. Even if you have a valid key card, it only grants access to your floor. The penthouse floor requires a gold card. If you tap a regular card on the penthouse elevator button, it denies you — not because you are not a guest, but because your role does not have permission. That is the difference between authentication (are you a guest?) and authorization (are you the right kind of guest?).
And the loading state? That is the brief moment the elevator reader blinks while it verifies your card. It does not open the doors, and it does not reject you. It waits until it knows for sure. Skipping this check — letting someone through before verification completes — is like the elevator opening for anyone while the card reader is rebooting.
Building the ProtectedRoute Component
The core idea is simple: create a wrapper component that checks authentication status before rendering its children. If the user is not authenticated, redirect to login. If auth is still loading, show a spinner.
Code Example 1: Basic ProtectedRoute with Redirect
import {
BrowserRouter, Routes, Route, Navigate, useLocation
} from "react-router-dom";
import { createContext, useContext, useState } from "react";
// Create an auth context to share authentication state across the app
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
// Login sets the user object — in a real app, this would call an API
function login(userData) {
setUser(userData);
}
// Logout clears the user
function logout() {
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Custom hook to access auth state from any component
function useAuth() {
return useContext(AuthContext);
}
// The ProtectedRoute component — this is the elevator key card check
function ProtectedRoute({ children }) {
const { user } = useAuth();
const location = useLocation();
// If no user is logged in, redirect to the login page
// Pass the current location in state so we can redirect back after login
if (!user) {
return (
<Navigate
to="/login"
state={{ from: location.pathname }}
replace
/>
);
}
// User is authenticated — render the protected content
return children;
}
function Dashboard() {
const { user, logout } = useAuth();
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user.name}!</p>
<button onClick={logout}>Log Out</button>
</div>
);
}
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* Wrap any route that requires authentication */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
// Unauthenticated user visits /dashboard:
// Output: Redirected to /login with state { from: "/dashboard" }
// Authenticated user visits /dashboard:
// Output: "Dashboard" page with "Welcome, Alice!"
Key point: The Navigate component with replace ensures the protected URL does not stay in history. Without replace, pressing the back button after login would land on the protected route, which would redirect to login again — an infinite loop of confusion.
Redirecting Back After Login
The redirect is only half the story. A good authentication flow remembers where the user was trying to go and sends them there after a successful login.
Code Example 2: Login Page with Intended Destination Redirect
import { useNavigate, useLocation } from "react-router-dom";
import { useState } from "react";
function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState("");
// Retrieve the page the user was trying to access before being redirected
// Falls back to /dashboard if the user navigated directly to /login
const from = location.state?.from || "/dashboard";
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get("email");
const password = formData.get("password");
try {
// Simulate API authentication
const userData = await fakeAuthAPI(email, password);
// Store the user in auth context
login(userData);
// Redirect to the originally intended page
// replace: true removes the login page from history
navigate(from, { replace: true });
} catch (err) {
setError("Invalid email or password.");
}
}
return (
<div>
<h1>Login</h1>
{error && <p style={{ color: "red" }}>{error}</p>}
{/* Show the user where they will be redirected after login */}
{from !== "/dashboard" && (
<p>You need to log in to access {from}</p>
)}
<form onSubmit={handleSubmit}>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Log In</button>
</form>
</div>
);
}
function fakeAuthAPI(email, password) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (email === "admin@test.com" && password === "password") {
resolve({ name: "Alice", email, role: "admin" });
} else if (email === "user@test.com" && password === "password") {
resolve({ name: "Bob", email, role: "user" });
} else {
reject(new Error("Invalid credentials"));
}
}, 500);
});
}
// Flow:
// 1. User visits /settings (protected)
// 2. ProtectedRoute redirects to /login with state: { from: "/settings" }
// 3. Login page shows: "You need to log in to access /settings"
// 4. User logs in successfully
// 5. navigate("/settings", { replace: true })
// 6. User lands on /settings — pressing back skips the login page
Key point: The location.state object is the mechanism that carries the intended destination through the redirect. It survives the navigation from the protected route to the login page. This pattern is the industry standard for post-login redirects.
Handling the Loading State
In real applications, checking authentication is asynchronous — you might verify a token with an API, wait for a cookie to be validated, or rehydrate a session from localStorage. During this time, you must not redirect to login (the user might be authenticated), and you must not show the protected content (the user might not be authenticated).
Code Example 3: ProtectedRoute with Loading State
import { Navigate, useLocation } from "react-router-dom";
import { createContext, useContext, useState, useEffect } from "react";
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
// isLoading starts as true — we do not know auth status yet
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// On app mount, check if the user has a valid session
// This simulates verifying a token with the server
async function checkAuth() {
try {
const token = localStorage.getItem("authToken");
if (token) {
// Verify the token with the backend
const userData = await verifyToken(token);
setUser(userData);
}
} catch (err) {
// Token is invalid or expired — clear it
localStorage.removeItem("authToken");
setUser(null);
} finally {
// Whether success or failure, loading is done
setIsLoading(false);
}
}
checkAuth();
}, []);
function login(userData, token) {
localStorage.setItem("authToken", token);
setUser(userData);
}
function logout() {
localStorage.removeItem("authToken");
setUser(null);
}
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
return useContext(AuthContext);
}
function ProtectedRoute({ children }) {
const { user, isLoading } = useAuth();
const location = useLocation();
// While checking auth status, show a loading indicator
// Do NOT redirect yet — we do not know if the user is authenticated
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: "50px" }}>
<p>Verifying authentication...</p>
</div>
);
}
// Auth check is complete — if no user, redirect to login
if (!user) {
return (
<Navigate
to="/login"
state={{ from: location.pathname }}
replace
/>
);
}
// Authenticated — render the protected content
return children;
}
function verifyToken(token) {
// Simulates a server call to verify the auth token
return new Promise((resolve, reject) => {
setTimeout(() => {
if (token === "valid-token-123") {
resolve({ name: "Alice", role: "admin" });
} else {
reject(new Error("Token expired"));
}
}, 800);
});
}
// Flow:
// 1. App mounts -> isLoading is true
// 2. ProtectedRoute renders "Verifying authentication..."
// 3. Token verification completes (800ms)
// 4. If valid: isLoading = false, user = { name: "Alice" } -> shows Dashboard
// 5. If invalid: isLoading = false, user = null -> redirects to /login
Key point: The loading state is the most commonly missed piece in interview answers. Without it, every page refresh on a protected route briefly redirects to login before the token verification completes — a flash of the login page that makes the app feel broken.
Role-Based Access Control
Authentication answers "who are you?" Authorization answers "what are you allowed to do?" A role-based ProtectedRoute checks both.
Code Example 4: Role-Based Route Guard
import { Navigate, useLocation } from "react-router-dom";
// Extended ProtectedRoute that accepts an optional list of allowed roles
function ProtectedRoute({ children, allowedRoles }) {
const { user, isLoading } = useAuth();
const location = useLocation();
// Still checking auth — show loading
if (isLoading) {
return <div>Verifying authentication...</div>;
}
// Not logged in — redirect to login
if (!user) {
return (
<Navigate to="/login" state={{ from: location.pathname }} replace />
);
}
// Logged in but wrong role — show an "access denied" page
// Do NOT redirect to login — the user IS authenticated, just not authorized
if (allowedRoles && !allowedRoles.includes(user.role)) {
return (
<div style={{ textAlign: "center", padding: "50px" }}>
<h1>403 — Access Denied</h1>
<p>You do not have permission to view this page.</p>
<p>Required role: {allowedRoles.join(" or ")}</p>
<p>Your role: {user.role}</p>
</div>
);
}
// Authenticated and authorized — render the content
return children;
}
// Usage in route definitions
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public routes — no protection */}
<Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage />} />
{/* Protected — any authenticated user */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
{/* Protected — only users with "admin" role */}
<Route
path="/admin"
element={
<ProtectedRoute allowedRoles={["admin"]}>
<AdminPanel />
</ProtectedRoute>
}
/>
{/* Protected — admins and moderators, but not regular users */}
<Route
path="/moderation"
element={
<ProtectedRoute allowedRoles={["admin", "moderator"]}>
<ModerationQueue />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
// Admin user visits /admin:
// Output: AdminPanel renders normally
// Regular user visits /admin:
// Output: "403 — Access Denied. Required role: admin. Your role: user"
// Unauthenticated user visits /admin:
// Output: Redirected to /login with state { from: "/admin" }
Key point: Authorization failure (wrong role) and authentication failure (not logged in) require different responses. Redirecting an authorized-but-wrong-role user to login is a mistake — they are already logged in. Show them a 403 page instead.
Common Mistakes
Mistake 1: Not handling the loading state before redirecting
// BAD: Redirects to login on every page refresh before auth check completes
function ProtectedRoute({ children }) {
const { user } = useAuth();
// On initial load, user is null while the token is being verified
// This immediately redirects to login even for authenticated users
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
// GOOD: Wait for the auth check to finish before making a decision
function ProtectedRoute({ children }) {
const { user, isLoading } = useAuth();
// Show a loading state while verifying the token
if (isLoading) {
return <div>Checking authentication...</div>;
}
// Only redirect after we are certain the user is not authenticated
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
Mistake 2: Redirecting unauthorized users to login instead of showing 403
// BAD: User IS logged in but gets sent to login again — confusing loop
function ProtectedRoute({ children, requiredRole }) {
const { user } = useAuth();
// User is logged in as "user" but requiredRole is "admin"
// Redirecting to login makes no sense — they are already authenticated
if (!user || user.role !== requiredRole) {
return <Navigate to="/login" replace />;
}
return children;
}
// GOOD: Separate authentication failure from authorization failure
function ProtectedRoute({ children, requiredRole }) {
const { user } = useAuth();
// Not logged in at all — redirect to login
if (!user) {
return <Navigate to="/login" replace />;
}
// Logged in but wrong role — show access denied, not login
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
return children;
}
Mistake 3: Forgetting to save the intended destination before redirecting
// BAD: After login, user always goes to /dashboard regardless of where they were
function ProtectedRoute({ children }) {
const { user } = useAuth();
if (!user) {
// No state passed — the login page has no idea where the user wanted to go
return <Navigate to="/login" replace />;
}
return children;
}
// GOOD: Pass the current location so login can redirect back to it
function ProtectedRoute({ children }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
// Save the intended destination in navigation state
return (
<Navigate to="/login" state={{ from: location.pathname }} replace />
);
}
return children;
}
Interview Questions
Q: How do you create a protected route in React Router?
Create a wrapper component (often called ProtectedRoute or RequireAuth) that checks the user's authentication status from context or a global store. If the user is authenticated, render the children. If not, use the
Navigatecomponent to redirect to the login page. Passreplaceso the protected URL does not stay in history, and pass the current location instateso the login page can redirect back after successful authentication.
Q: How do you redirect users back to their intended page after login?
When the ProtectedRoute redirects to login, it passes the current path via
Navigate'sstateprop:state={{ from: location.pathname }}. On the login page, read this withuseLocation().state?.fromand fall back to a default like "/dashboard". After successful login, callnavigate(from, { replace: true })to send the user to their original destination while removing the login page from browser history.
Q: Why is a loading state important in protected routes?
On initial page load or refresh, authentication status is unknown until an async check completes (verifying a token, rehydrating a session). Without a loading state, the ProtectedRoute sees
user = nullbefore the check finishes and immediately redirects to login — even for authenticated users. This causes a visible flash of the login page. The fix is to track anisLoadingboolean, show a spinner while it is true, and only make the redirect-or-render decision after loading completes.
Q: What is the difference between authentication and authorization in the context of protected routes?
Authentication verifies identity — "is this user logged in?" Authorization verifies permissions — "does this user have the right role or access level?" A protected route should handle both separately. If a user is not authenticated, redirect to login. If a user is authenticated but lacks the required role, show a 403 "Access Denied" page instead of redirecting to login. Conflating the two creates confusing UX where a logged-in user keeps getting sent back to a login form.
Q: How would you implement route protection using React Router's layout routes instead of wrapping each route individually?
React Router v6 supports layout routes where a parent route renders an
<Outlet />. You can make the parent a ProtectedRoute that checks auth and renders<Outlet />for authorized users. All child routes automatically inherit the protection. This avoids wrapping every single route:<Route element={<ProtectedRoute />}> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> </Route>. The ProtectedRoute component renders<Outlet />instead ofchildren, and every nested route is guarded with a single wrapper.
Quick Reference — Cheat Sheet
PROTECTED ROUTES & ROUTE GUARDS
=================================
Basic ProtectedRoute:
function ProtectedRoute({ children }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
return children;
}
Wrapping routes:
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
Layout route pattern (protects all children):
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Route>
// ProtectedRoute renders <Outlet /> instead of children
Role-based protection:
<ProtectedRoute allowedRoles={["admin"]}>
<AdminPanel />
</ProtectedRoute>
Saving intended destination:
Redirect: <Navigate to="/login" state={{ from: location.pathname }} replace />
Read: const from = location.state?.from || "/dashboard";
After login: navigate(from, { replace: true });
Three checks in order:
1. isLoading? -> Show spinner (do NOT redirect)
2. user null? -> Redirect to /login (authentication failure)
3. role wrong? -> Show 403 page (authorization failure)
+---------------------+--------------------------+---------------------------+
| Scenario | Response | Why |
+---------------------+--------------------------+---------------------------+
| Auth loading | Show spinner | Do not act on unknown |
| Not authenticated | Redirect to /login | User needs to log in |
| Wrong role | Show 403 page | User IS logged in |
| Authenticated+role | Render protected content | All checks pass |
+---------------------+--------------------------+---------------------------+
Auth context essentials:
user -> Current user object (null if not logged in)
isLoading -> True while verifying auth on mount
login() -> Set user + store token
logout() -> Clear user + remove token
Common patterns:
Post-login redirect: navigate(from, { replace: true })
Protect group of routes: Layout route with <Outlet />
Role-based guard: allowedRoles prop on ProtectedRoute
Auth persistence: Verify token in useEffect on mount
Logout cleanup: Clear token, setUser(null), navigate to /login
Previous: Lesson 7.2 — Dynamic Routes & Navigation -> Next: Lesson 8.1 — Identifying Performance Problems ->
This is Lesson 7.3 of the React Interview Prep Course — 10 chapters, 42 lessons.