How to Handle Authentication and Protected Routes in React

Advanced10 min interview
Skills tested:
Building a ProtectedRoute component that checks auth state and redirectsHandling isLoading to prevent a flash redirect during async session initializationPreserving the originally-attempted URL in location state for post-login redirectExtending protection to role-based access controlComposing multiple protected routes in a route config without repetition

Advertisement

🧩 Scenario

Protected routes are a standard requirement in any authenticated React app. Interviewers look for three things: the guard logic itself, the loading state that prevents a flash redirect on page refresh, and the redirect-back-to-origin pattern that keeps users from losing their place after login.

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 locationFirst-paint behaviorComplexityBest for
Client-side guardShows spinner briefly during async checkLowSPAs with client-side auth context
Server-side redirect (Next.js middleware)No flash; redirect happens before HTML is sentMediumNext.js apps; SSR-first projects
BothZero flash with strong server enforcementHigherHigh-security apps

What Interviewers Actually Check

  • Whether you handle the isLoading state and can explain what happens if you do not
  • Whether you know replace is 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

  1. In Next.js App Router, how would you implement protected routes using middleware compared to a client-side ProtectedRoute component?
  2. 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?
  3. How do you write a test with React Testing Library that verifies ProtectedRoute redirects an unauthenticated user to /login?
  4. How does useAuth get the session to the ProtectedRoute? Describe the full data flow from session storage to component.
  5. 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 /login on 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 replace on the login redirect — creates a history loop where back goes to login indefinitely
  • Not reading location.state.from after login — users always land on the default page instead of where they were trying to go
  • Forgetting that useLocation must 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 replace is 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.

Frequently Asked Questions

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