Skip to content

React · Routing

React Router v6 Protected Routes — Complete Interview Guide

9 min read Updated 2026-06-24 Share:

Practice Protected Routes interview questions

Why Protected Routes Matter

A React single-page application ships its entire JavaScript bundle to the browser. Without an explicit guard, any visitor who types /admin or /dashboard into the address bar will see your protected pages — because the browser downloads and executes your JS regardless of who is asking. Client-side routing happens entirely in JavaScript; there is no web server turning away unauthorized requests at the URL level. This means you must explicitly intercept route rendering and redirect unauthenticated users before their component mounts.

The second reason protected routes matter is developer experience and architecture. Rather than sprinkling auth checks across every page component, a centralized guard acts as middleware — one place that enforces access policy for an entire subtree of routes. Interviewers ask about this pattern because it touches core React concepts (context, composition, rendering) as well as security reasoning (client-side vs. server-side trust boundaries). Knowing it cold signals that you can build production-grade auth, not just hello-world tutorials.

The RequireAuth Wrapper

React Router v6's layout route pattern is the cleanest way to guard routes. A layout route is a <Route> with an element but no path — it renders for any matched child route. Combine this with <Navigate> for declarative redirection and <Outlet> to pass through to children.

// guards/RequireAuth.jsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

export function RequireAuth() {
  const { user, loading } = useAuth();
  const location = useLocation(); // capture current URL before redirecting

  // Wait for silent-refresh to complete — prevents premature redirect
  if (loading) return <div aria-label="Checking auth…" />;

  if (!user) {
    // Pass current location so login can redirect back after success
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return <Outlet />; // authenticated — render matched child route
}

Wire it into your router by nesting protected routes beneath it with no path on the guard itself:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/login" element={<LoginPage />} />   {/* must be outside */}

  <Route element={<RequireAuth />}>                 {/* layout guard */}
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/settings" element={<Settings />} />
  </Route>
</Routes>

Two details matter here. First, replace on <Navigate> overwrites the history entry rather than pushing — without it, clicking Back after login sends the user to the protected URL again, which redirects again, creating a loop. Second, the loading check is mandatory if auth state is restored asynchronously (silent refresh, cookie validation) — without it the guard fires before it knows the answer and redirects every valid user to login on a hard refresh.

Redirect-After-Login: Preserving the Intended Destination

When a user bookmarks /dashboard, closes the tab, reopens it, and gets sent to /login, they expect to land back on /dashboard after logging in — not a generic home page. The mechanism is React Router's location state.

The guard passes the current location object in the redirect's state.from field. The login page reads that value after a successful login and calls navigate() with it:

// pages/LoginPage.jsx
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

export function LoginPage() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();

  // Fall back to /dashboard if user navigated directly to /login
  const from = location.state?.from?.pathname ?? '/dashboard';

  async function handleSubmit(credentials) {
    await login(credentials);            // authenticate with server
    navigate(from, { replace: true });   // go to original destination
  }

  // render form…
}

The ?? '/dashboard' fallback is critical — if the user navigates directly to /login there is no state, and reading location.state?.from?.pathname returns undefined. Always provide a sensible default.

Role-Based Access Control

Authentication answers "who are you?"; authorization answers "what are you allowed to do?" Extend RequireAuth with a RequireRole guard that checks user.role against an allowedRoles array:

// guards/RequireRole.jsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

export function RequireRole({ allowedRoles }) {
  const { user, loading } = useAuth();
  const location = useLocation();

  if (loading) return null;

  if (!user) {
    // Not logged in → send to login (don't reveal the page exists)
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  if (!allowedRoles.includes(user.role)) {
    // Logged in but wrong role → send to forbidden page
    return <Navigate to="/403" replace />;
  }

  return <Outlet />;
}

Stack guards by nesting layout routes — each layer checks one concern:

<Route element={<RequireAuth />}>              {/* layer 1: logged in */}
  <Route path="/dashboard" element={<Dashboard />} />

  <Route element={<RequireRole allowedRoles={['admin']} />}> {/* layer 2: role */}
    <Route path="/admin" element={<AdminPanel />} />
    <Route path="/admin/users" element={<UserManager />} />
  </Route>
</Route>

Keep the two error destinations distinct: unauthenticated users should see the login page (they might have a valid account); authenticated users with the wrong role should see 403 (logging in again won't help). Conflating them creates confusing UX.

The AuthContext + useAuth Hook Pattern

Route guards are only as good as the auth state they read. AuthContext centralizes that state — user object, loading flag, login, and logout — and exposes it via a useAuth hook that throws if used outside the provider.

// context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { api } from '../lib/api';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true); // true until session is checked

  useEffect(() => {
    // Restore session from httpOnly refresh-token cookie on every page load
    api.post('/auth/refresh')
      .then(({ data }) => setUser(data.user))
      .catch(() => setUser(null))
      .finally(() => setLoading(false)); // guards can now decide
  }, []);

  const login = async (credentials) => {
    const { data } = await api.post('/auth/login', credentials);
    setUser(data.user);
  };

  const logout = async () => {
    await api.post('/auth/logout');
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// Throws a clear error if called outside provider — surfaces bugs immediately
export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
  return ctx;
}

Wrap <AuthProvider> around <RouterProvider> (or the <Routes> tree) at the top of your app so every guard can call useAuth(). The loading flag is the key piece — guards block rendering until the useEffect resolves, preventing a race between "user is null because we haven't checked yet" and "user is null because they're actually logged out."

For token storage, keep the access token in memory (React state) and rely on an httpOnly cookie for the refresh token. Memory tokens disappear on tab close (no XSS risk from localStorage), and the httpOnly cookie is invisible to JavaScript (no XSS risk at the refresh layer either).

Client-Side vs. Server-Side Protection

This distinction is the most common follow-up in interviews. Client-side route guards are a UX feature, not a security boundary. They prevent your components from rendering for unauthorized users, which is important for user experience and for not wasting API calls — but a determined attacker can bypass them entirely by calling your API endpoints directly with curl or a browser devtools fetch.

Server-side protection is the actual security. Every API endpoint that returns sensitive data must validate the auth token independently:

// Express middleware — real security boundary
function requireAuth(req, res, next) {
  const token = req.cookies.accessToken;
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: 'Unauthorized' });
  }
}

// Every sensitive endpoint uses the middleware
router.get('/api/dashboard', requireAuth, getDashboardData);
router.get('/api/admin/users', requireAuth, requireRole('admin'), getUsers);

The mental model: client-side guards protect pixels (your components), server-side guards protect data (your API responses). You need both. The client guard prevents a bad user experience; the server guard prevents a data breach.

Common Interview Questions at a Glance

  • What is a protected route? A conditional render — show the page if auth passes, redirect to login if not.
  • Why use replace on <Navigate>? To overwrite the history entry and prevent a back-button redirect loop.
  • How do you redirect back after login? Pass location in state={{ from: location }} on the guard's redirect; read location.state?.from?.pathname in the login handler.
  • What's the difference between RequireAuth and RequireRole? RequireAuth checks authentication (logged in?); RequireRole checks authorization (right role?). Stack them as nested layout routes.
  • Why keep loading: true until auth is resolved? To prevent the guard from redirecting a valid user to login during the async session-check on page refresh.
  • Is client-side protection enough? No — it's UX only. Server endpoints must validate tokens independently; client guards can be bypassed with direct API calls.
  • Where should you store access tokens? In-memory (React state) to avoid XSS; use an httpOnly cookie for the refresh token so sessions survive page reloads without localStorage.
  • How do you test a protected route? Render with a mock AuthContext.Provider inside MemoryRouter, set user: null to test the redirect, set a user object to test the protected component renders.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel