React Interview Prep
React Router

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


Protected Routes & Route Guards thumbnail


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.


Protected Routes & Route Guards visual 1


Protected Routes & Route Guards visual 2


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 Navigate component to redirect to the login page. Pass replace so the protected URL does not stay in history, and pass the current location in state so 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's state prop: state={{ from: location.pathname }}. On the login page, read this with useLocation().state?.from and fall back to a default like "/dashboard". After successful login, call navigate(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 = null before 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 an isLoading boolean, 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 of children, 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.

On this page