In a React Router data router, what happens step by step when a route's loader throws redirect('/login?redirectTo=/admin/users')?
answer
- a Response, not a function call
- 302 plus a Location header
- loaders settle, then redirect
- the protected URL never lands in history
basics
~20 sredirect() 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 linesimport { 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
Know that a data-router loader can throw redirect('/login') to send a signed-out user away before the page renders.
Walk through the sequence: redirect builds a 302 Response, loaders settle, the router starts a new navigation, and the protected URL never reaches history.
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.
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