Back to React
React
medium
mid

How would you implement protected routes and authentication in React?

Wrap protected routes in a guard component that reads auth state from a context/store and either renders the children, redirects to `/login` (with a `?next` param so post-login lands on the requested page), or renders a loading state during the auth probe. For role-based access, wrap a second guard around routes that require specific roles. The frontend guard is **UX**, not security — every protected API endpoint must enforce auth server-side independently.

8 min read·~15 min to think through

Two questions: (1) how do you make the right pages visible to the right users, (2) what does that not protect against.

The frontend pattern

tsx
function RequireAuth({ children }: { children: React.ReactNode }) {
  const { status, user } = useAuth();
  const location = useLocation();

  if (status === "loading") return <FullPageSpinner />;
  if (status === "unauthenticated") {
    return <Navigate to={`/login?next=${encodeURIComponent(location.pathname)}`} replace />;
  }
  return <>{children}</>;
}

function RequireRole({ role, children }: { role: Role; children: React.ReactNode }) {
  const { user } = useAuth();
  if (!user?.roles.includes(role)) return <Forbidden />;
  return <>{children}</>;
}

// usage
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
<Route path="/admin" element={<RequireAuth><RequireRole role="admin"><Admin /></RequireRole></RequireAuth>} />

Three states, not two: loading is the one people forget. During the initial auth probe (cookie present, hit /me), you don't know yet — rendering "unauthenticated → redirect to /login" prematurely will redirect already-logged-in users.

Auth state in context

tsx
function AuthProvider({ children }) {
  const [state, setState] = useState({ status: "loading" });

  useEffect(() => {
    api.get("/me")
      .then(user => setState({ status: "authenticated", user }))
      .catch(() => setState({ status: "unauthenticated" }));
  }, []);

  return <AuthContext value={state}>{children}</AuthContext>;
}

Or, in 2026, use TanStack Query for the /me fetch (caching, retry, refetch on focus all come for free).

Post-login redirect

The ?next= param ensures clicking a deep link, getting bounced to login, and authenticating lands them back where they wanted:

tsx
function LoginPage() {
  const [params] = useSearchParams();
  const next = params.get("next") || "/";
  const navigate = useNavigate();

  async function onSubmit(creds) {
    await api.login(creds);
    navigate(next, { replace: true });
  }
  ...
}

Validate next. Reject external URLs to prevent open-redirect (?next=https://evil.com). Allow only path-relative URLs.

Role-based / permission-based access (RBAC)

Two flavors:

  • Roles — coarse (admin, member, viewer).
  • Permissions — fine (order:write, user:delete).

Permissions scale better. UI helper:

tsx
function Can({ permission, children, fallback = null }) {
  const { user } = useAuth();
  return user?.permissions?.includes(permission) ? <>{children}</> : fallback;
}

// usage
<Can permission="user:delete">
  <button onClick={deleteUser}>Delete</button>
</Can>

Use Can to hide actions; use route guards to gate pages. Don't only hide buttons — a user can still hit the API endpoint directly.

Server-side rendering (Next.js)

tsx
// middleware.ts — runs at the edge, before the page renders
export function middleware(req: NextRequest) {
  const token = req.cookies.get("session")?.value;
  if (!token && req.nextUrl.pathname.startsWith("/app")) {
    const url = req.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("next", req.nextUrl.pathname);
    return NextResponse.redirect(url);
  }
}

Better than client-side guards for protected SSR pages because:

  • No flash of the protected page before redirect.
  • Search engines don't index the protected route's HTML.
  • The protected page's data fetch can rely on the user being authenticated.

The 5 mistakes interviewers look for

1. Treating the frontend guard as security. It isn't. A user can edit JS in DevTools and force <RequireAuth> to render its children. The API must independently enforce auth on every endpoint. The frontend guard is for UX — showing the right pages.

2. Missing the loading state. Premature redirect flickers users who are actually authenticated.

3. Re-fetching /me on every navigation. Cache it; refetch only on focus/reconnect/logout.

4. Storing tokens in localStorage. XSS-reachable. Use HttpOnly cookies.

5. No logout strategy. What happens to in-flight requests, query cache, open WebSockets, other tabs? On logout: clear query cache, close WebSockets, broadcast to other tabs, redirect to login.

tsx
function logout() {
  api.post("/logout").catch(() => {});
  queryClient.clear();
  realtime.disconnect();
  bc.postMessage({ type: "logout" });  // tell other tabs
  window.location.href = "/login";
}

Cross-tab session sync

tsx
useEffect(() => {
  const bc = new BroadcastChannel("auth");
  bc.onmessage = (e) => {
    if (e.data.type === "logout") window.location.reload();
    if (e.data.type === "login") refetchMe();
  };
  return () => bc.close();
}, []);

A user logging out in one tab should be logged out everywhere.

Senior framing

The interviewer is testing:

  1. Three auth states, not two.
  2. ?next= for deep-link UX, with open-redirect validation.
  3. RBAC pattern with reusable guards.
  4. SSR/middleware guards when applicable.
  5. Logout cleanup across cache, WebSockets, tabs.
  6. Explicit acknowledgment that the frontend guard is UX, not security.

The "I wrap protected routes in <RequireAuth>" answer is mid. The full answer above is senior.

Follow-up questions

  • Why is the loading state critical?
  • Why is hiding a delete button not enough for protection?
  • How does middleware-based auth in Next.js avoid SSR flash?
  • What needs to happen on logout besides clearing the token?

Common mistakes

  • Premature redirect to login during auth probe.
  • Trusting frontend role checks for security.
  • Storing tokens in localStorage.
  • No cross-tab logout sync.

Performance considerations

  • Cache /me — don't refetch on every navigation.
  • Middleware auth at the edge avoids SSR cost for unauthenticated users.

Edge cases

  • Token expired mid-session — 401 should trigger refresh, not redirect on every request.
  • Multi-tab login — broadcast to refetch.
  • Server clock drift causing token-not-yet-valid errors.

Real-world examples

  • Next.js middleware for /app prefix protection.
  • Auth0, Clerk, Supabase Auth all provide the AuthProvider + guard pattern.

Related questions