skip to content

Route Protection

Auth guards in practice: redirect from a loader, wrap the route in a guard component, and preserve the intended destination so the user lands where they meant to after signing in. A near-guaranteed question in any front-end interview that touches auth.

on this pageshow

explore

questions

4

In React Router, how do you write a RequireAuth wrapper that sends signed-out users to /login while remembering the page they asked for?

level: juniorimportance: must knowfreq 72%

answer

  1. a component that renders instead of children
  2. Navigate, not navigate() in render
  3. replace the history entry
  4. location goes into state

basics

~10 s

A RequireAuth component reads the auth state and useLocation(); when signed out it returns <Navigate to="/login" replace state={{ from: location }} />, otherwise its children or <Outlet />. The login page later reads location.state.from.

solid answer

~40 s

The guard is an ordinary component. It reads the current user from your auth context and the current location with `useLocation()`. If there is no user it returns `<Navigate to="/login" replace state={{ from: location }} />`; otherwise it returns `children`, or `<Outlet />` when it is used as a layout route wrapping the whole `/admin` subtree. `replace` swaps the protected entry for `/login` so Back does not bounce the user into the guard again. `state` travels with the new history entry, so the login page can read `location.state?.from` and return there. Guards return `<Navigate>` because calling `navigate()` during render is not allowed — React Router warns to call it in an effect, and `<Navigate>` does exactly that. Remember it is UX only: the API still has to authorize every request.

code

tsx · 24 lines
tsx
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router";
import { useAuth } from "./auth";
import { AdminHome, AdminUsers, LoginPage } from "./pages";

function RequireAuth() {
  const { user } = useAuth();
  const location = useLocation();
  if (!user) {
    return <Navigate to="/login" replace state={{ from: location }} />;
  }
  return <Outlet />;
}

export function AppRoutes() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route element={<RequireAuth />}>
        <Route path="/admin" element={<AdminHome />} />
        <Route path="/admin/users" element={<AdminUsers />} />
      </Route>
    </Routes>
  );
}

go deeper

for a junior

Be able to write the component: useLocation, a Navigate with replace and state when signed out, Outlet or children otherwise.

for a middle

Explain why Navigate is used instead of navigate() in render, what replace does to history, and where state is stored.

for a senior

Place the guard as a layout route with public pages outside it, handle the unknown-session case, and recognise that in a data router loaders run before a wrapper can act.

for a principal

Decide where access checks live across the app: wrapper guards for UX, loader or middleware guards for data routers, and mandatory authorization on every API.

## The pattern A **route guard** decides whether a navigation may show a screen. In React Router's declarative style the guard is a plain React component — commonly called `RequireAuth` — that renders either the protected content or a redirect. It uses three React Router pieces: - **`useLocation()`** — returns the current `location` object (`pathname`, `search`, `hash`, `state`, `key`). - **`<Navigate>`** — a component that performs a navigation when it renders. Its props are `to`, `replace`, `state` and `relative`. - **`<Outlet />`** — renders the matched child route, so one guard placed on a parent can protect a whole subtree. ## Why each piece is there | Piece | What it does | What goes wrong without it | |---|---|---| | `useLocation()` | captures where the user was going | the login page cannot send them back | | `<Navigate to="/login">` | redirects from inside render safely | calling `navigate()` in the render body triggers React Router's "call navigate() in a React.useEffect()" warning | | `replace` | replaces the protected entry in the history stack | Back returns to `/admin/users`, the guard fires again, and the user is stuck in a loop | | `state={{ from: location }}` | stores the intended destination on the new history entry | the destination is lost, or has to be put in the URL | | `children` / `<Outlet />` | renders the protected content when allowed | the guard can only wrap one element instead of a subtree | `<Navigate>` performs its navigation from an effect after it renders, so the guard's render output for a signed-out user is just the `<Navigate>` element: the protected children are never rendered. ## Protecting the `/admin` subtree Use the guard as a **layout route** — a route with an element but no path — so every admin page is covered by one component: 1. Define a route whose element is `<RequireAuth />` and give it the admin routes as children. 2. Inside `RequireAuth`, return `<Outlet />` when the user is signed in. 3. Keep `/login` **outside** that layout route; if the login page were inside it, the guard would redirect the login page to itself. The same shape works with route objects in a data router: `{ element: <RequireAuth />, children: [...] }`. ## Reading the destination on the login page `location.state` is whatever was passed as `state` — here the whole previous `location`. The login page reads `location.state?.from?.pathname` (plus `search` if you need it), and after a successful sign-in calls `navigate(from, { replace: true })`, falling back to a default such as `/` when `state` is empty. Returning the user, and validating a destination that comes from the URL instead, are covered in their own question. ## Limits of a wrapper guard 1. **It is a UX feature, not security.** Anyone can call the API directly or edit client code; every admin endpoint must check the session and the role itself. 2. **`state` lives only in that history entry.** It is not part of the URL, so it does not survive opening the login link in a new tab or sharing it. 3. **In a data router, a wrapper runs after loaders.** The router runs a route's loaders before rendering any of its components, so admin loaders have already fetched by the time `RequireAuth` renders. Data-router apps usually guard in a loader or middleware instead. 4. **Unknown auth state needs its own branch.** If the session is still loading, render a placeholder rather than redirecting, or a signed-in user is bounced to `/login` on reload. ## Role checks in the same wrapper The same component shape handles authorization for the UI: read the user's roles, and for a signed-in user without the `admin` role render a "not allowed" screen or `<Navigate to="/" replace />` instead of `<Outlet />`. Keep the two branches distinct — signed out goes to `/login` with `state`, signed in but not permitted does **not**, or the login page would bounce a signed-in user in circles. ## Common mistakes - Omitting `replace`, which creates the Back-button loop. - Putting `/login` inside the protected layout, which creates a redirect loop. - Storing only `location.pathname` and losing the query string the user needed. - Treating the guard as the only access control. In an interview, write the eight-line component, then name `replace`, `state` and "UX, not security" without being asked.

  • Why not call navigate('/login') directly in the guard's body?
    Navigating during render is a side effect in the middle of React's render phase. React Router warns that `navigate()` should be called in a `React.useEffect()`, not on first render. `<Navigate>` wraps exactly that: it renders nothing and navigates from an effect, which is why guards return it.
  • What does the user experience if you forget replace on the Navigate?
    History holds the protected URL followed by `/login`. Pressing Back from the login page returns to the protected URL, the guard redirects to `/login` again, and the user cannot get back to where they came from. `replace` swaps the protected entry out, so Back leaves the flow cleanly.
  • Why must /login sit outside the RequireAuth layout route?
    The guard redirects every signed-out visit to its children. If `/login` were a child, a signed-out user visiting `/login` would be redirected to `/login` again, forever. Public routes such as login, sign-up and password reset belong outside the protected layout.

saying these in an interview costs you the question

  • Calling navigate('/login') directly in the component body is the normal way
  • replace is only cosmetic and does not affect the Back button
  • location.state survives when the login link is opened in a new tab
  • Hiding the admin route in the client is enough to protect admin data
  • Each protected page needs its own copy of the guard component
open as a page

In a React Router data router, what happens step by step when a route's loader throws redirect('/login?redirectTo=/admin/users')?

level: middleimportance: must knowfreq 62%

basics

~20 s

redirect() builds a 302 Response with a Location header. When a loader throws it, the router lets the navigation's loaders settle, then starts a new navigation to /login; the protected page never renders and its URL is never committed to history.

open as a page

In React Router v7, how does a login page send the user back to location.state.from or a redirectTo param, and what must it validate?

level: middleimportance: should knowfreq 55%

basics

~20 s

After sign-in, a component calls navigate(from, { replace: true }); a data-router login action returns redirect(target). A redirectTo read from the URL is attacker-controllable, so accept only a same-app path starting with a single '/' and fall back to '/'.

open as a page

A React Router data router guards /admin with a parent loader that throws redirect, yet signed-out visits still hit the admin API from a child loader; why, and what fixes it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The data router runs all matched loaders in parallel and acts on a redirect only after they settle, so the child loader fetches anyway. Guard with route middleware (stable since 7.9.0) or a check in every loader, and authorize on the server.

open as a page