skip to content

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%

answer

  1. a Response, not a function call
  2. 302 plus a Location header
  3. loaders settle, then redirect
  4. the protected URL never lands in history

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.

solid answer

~40 s

`redirect(url, init?)` does not navigate by itself: it returns a `Response` with status 302 (or the status you pass) and a `Location` header. Throwing it — or returning it — from a loader tells the data router the navigation must go elsewhere. The router matches `/admin/users`, runs the matched loaders, and once they have settled it finds the redirect result and starts a fresh navigation to `/login?redirectTo=…`, running that route's loaders before committing. Because the data router only writes to history when a navigation completes, `/admin/users` is never pushed, and none of its components mount. Put the check in a shared `requireUser(request)` helper on a pathless parent route, build the target from `new URL(request.url)`, and encode it with `URLSearchParams`. Use `replace()` when you want a replace navigation.

code

ts · 23 lines
ts
import { createBrowserRouter, redirect, type LoaderFunctionArgs } from "react-router";
import { getSession } from "./session";
import { AdminUsers, adminUsersLoader, LoginPage } from "./pages";

async function requireUser({ request }: LoaderFunctionArgs) {
  const session = await getSession();
  if (!session) {
    const url = new URL(request.url);
    const params = new URLSearchParams({ redirectTo: url.pathname + url.search });
    throw redirect(`/login?${params}`);
  }
  return null;
}

export const router = createBrowserRouter([
  { path: "/login", Component: LoginPage },
  {
    loader: requireUser, // pathless guard route: renders its child via an implicit outlet
    children: [
      { path: "/admin/users", Component: AdminUsers, loader: adminUsersLoader },
    ],
  },
]);

go deeper

for a junior

Know that a data-router loader can throw redirect('/login') to send a signed-out user away before the page renders.

for a middle

Walk through the sequence: redirect builds a 302 Response, loaders settle, the router starts a new navigation, and the protected URL never reaches history.

for a senior

Place the guard on a pathless parent, build the destination from the request, choose replace() or redirectDocument() deliberately, and remember that sibling and child loaders still run.

for a principal

Standardise guards across the route tree as shared loader or middleware helpers, and make server-side authorization a non-negotiable pairing for every guarded route.

## What `redirect()` actually is In a **data router** (`createBrowserRouter` + `<RouterProvider>`), a **loader** is a function the router calls before rendering a route. `redirect` is a small utility exported by `react-router`: - it returns a **`Response`** object, the same type `fetch()` returns, - the status defaults to **302**; you can pass another number or a `ResponseInit`, - the target goes in the **`Location`** header. It performs no navigation itself. The router inspects what the loader produced and, when it sees a redirect response, navigates. Both `throw redirect(...)` and `return redirect(...)` work; throwing is preferred for guards because it can be done from a helper such as `requireUser()` deep inside other code, and it keeps the loader's return type about data only. ## Step by step 1. The user clicks a link to `/admin/users`. The router matches the route branch — for example a pathless guard route and the `admin/users` child. 2. The router starts a **pending** navigation and calls the matched loaders. The URL bar and history have not changed yet. 3. The guard loader finds no session and throws `redirect("/login?redirectTo=%2Fadmin%2Fusers")`. 4. The router waits for the navigation's loaders to settle, then finds the redirect among the results. 5. It starts a **new navigation** to `/login?redirectTo=…`, runs the login route's loaders, and only then commits. 6. History now reads: previous page, then `/login?redirectTo=…`. `/admin/users` was never committed, and no admin component mounted. Step 5 is why a loader guard does not flash protected UI: the router never reached the render of the protected branch. ## Building the redirect target The loader receives `request`, a standard `Request` for the URL being navigated to. Derive the destination from it rather than from `window.location`, which still shows the *previous* page during a pending navigation: - `const url = new URL(request.url)`, - `const params = new URLSearchParams({ redirectTo: url.pathname + url.search })`, - `throw redirect("/login?" + params)`. `URLSearchParams` percent-encodes the nested path and query string, so `/admin/users?tab=roles` survives intact. ## Where the guard goes Put the check on a **pathless parent route** that wraps the protected subtree: `{ loader: requireUser, children: [...] }`. A route without a `Component` or `element` renders its matched child through an implicit outlet, so it adds no UI. Keep `/login` outside that parent. ## Loader guard versus wrapper component | | Loader throwing `redirect()` | Wrapper returning `<Navigate>` | |---|---|---| | When it runs | before any component of the branch renders | while rendering, after loaders ran | | Protected URL in history | never committed | committed, then replaced with `replace` | | Works in declarative mode | no — needs a data router | yes | | Carries destination via | query string | `location.state` or query string | ## Why throwing composes better A guard is rarely one line. Typically `requireUser(request)` reads the session, and `requireAdmin(request)` calls `requireUser` and then checks a role. Because a thrown `Response` unwinds through every caller, the innermost helper can end the navigation without each caller checking a return value — exactly like throwing an error. The data router catches thrown responses from loaders and, when the response is a redirect, follows it instead of rendering an error boundary. That is also why loader code after a `throw redirect(...)` never runs, while code after a `return redirect(...)` inside a helper would keep executing in the caller unless it checks the result. ## Related utilities - `replace(url)` — same response plus a header that makes the router use a **replace** navigation. - `redirectDocument(url)` — forces a **full document** load, useful after sign-out to drop in-memory state. ## One caveat By default the data router runs all matched loaders **in parallel**, so a child route's loader starts even while the parent guard is deciding to redirect. The redirect still wins, but the child's request was made. How to stop that — middleware, or checking in every loader — is its own question, and it is also why the server must authorize every API call regardless of the client guard.

  • Why build redirectTo from request.url rather than window.location?
    During a pending navigation the router has not committed yet, so `window.location` still shows the previous page. The loader's `request` describes the URL being navigated to, so `new URL(request.url)` gives the path and query the user actually asked for.
  • When would you use redirectDocument() instead of redirect()?
    When the target should load as a fresh document rather than a client-side navigation — typically after sign-out, to discard in-memory state such as caches and stores. It is the same redirect response plus a header telling the router to do a full page load.

saying these in an interview costs you the question

  • redirect() calls navigate() immediately, so code after it still runs
  • A loader must return, not throw, a redirect
  • The protected URL is pushed to history and then replaced
  • window.location inside a loader shows the URL being navigated to
  • A loader guard needs a wrapper component as well to stop the page rendering