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?
answer
- loaders start together
- redirects are read after settling
- middleware runs parent to child first
- the API is the real gate
basics
~20 sThe 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.
solid answer
~40 sA parent loader is not a gate in front of its children. The data router's default data strategy calls all matched loaders in parallel, and it looks for a redirect only after the loaders have settled — nothing is aborted on the way. So the child's `fetch('/api/admin/users')` goes out and returns 401 while the parent is throwing `redirect('/login')`; the user still ends up on `/login`, which hides the problem. Fixes: move the check into the parent route's `middleware` (stable since 7.9.0), which runs parent-to-child before any loader, so a thrown redirect means no loader runs; or call a shared `requireUser()` at the top of every protected loader. Either way the request proves the real point — the client guard is UX, and the API must authorize every call.
code
ts · 30 linesimport {
createBrowserRouter,
createContext,
redirect,
type LoaderFunctionArgs,
type MiddlewareFunction,
} from "react-router";
import { getUser, type User } from "./auth";
import { AdminUsers } from "./pages";
export const userContext = createContext<User | null>(null);
const requireUser: MiddlewareFunction = async ({ context }) => {
const user = await getUser();
if (!user) throw redirect("/login"); // no loader below this route runs
context.set(userContext, user);
};
async function adminUsersLoader({ context }: LoaderFunctionArgs) {
const user = context.get(userContext);
const res = await fetch("/api/admin/users"); // API still checks the session itself
return { user, users: await res.json() };
}
export const router = createBrowserRouter([
{
middleware: [requireUser],
children: [{ path: "/admin/users", Component: AdminUsers, loader: adminUsersLoader }],
},
]);go deeper
Know that in a data router, a guard on a parent loader does not stop child loaders from running.
Explain that the data router runs matched loaders in parallel and handles a redirect only after they settle, so a parent redirect cannot prevent a child's request.
Diagnose it from the network log, fix it with route middleware on 7.9.0+ or a check in every loader, and insist the API authorizes each request regardless.
Choose the org-wide guard mechanism for a large data-router app, weighing middleware adoption and version upgrades against per-loader discipline and custom data strategies.
## The symptom The route table has a **pathless guard route** with a loader, and the admin pages as its children: - guard route: `{ loader: requireUser, children: [...] }`, - child: `{ path: "/admin/users", loader: adminUsersLoader }`. A signed-out user opens `/admin/users`. They correctly end up on `/login`, but the network panel — or the server log — shows a request to `/api/admin/users` that returned 401. Nothing leaked on screen, so it is easy to miss, and a team may conclude the guard "works". ## Why it happens React Router's data router is built for speed: its default **data strategy** runs every matched route's loader **in parallel** to avoid request waterfalls. Two consequences follow: 1. The child's loader starts at the same moment as the parent's; it does not wait for the parent to succeed. 2. The router inspects loader results for a redirect **after** they have settled. The pinned source even notes there is no need to abort on redirects, because the redirect is not detected until the loaders have finished. So the parent's `throw redirect("/login")` decides where the user goes, but it cannot stop work already running. A parent loader is a *sibling in time*, not a gate. ## Fix 1: route middleware Since **7.9.0** React Router has stable **middleware**. In data mode it is an array on the route object: `middleware: [authMiddleware]`. Middleware runs as a nested chain — root, then parent, then child — **before** the loaders, and then back up after them. A middleware that throws `redirect("/login")` before calling `next()` means the loaders below it never run. - It fits the guard use case exactly: one function on the guard route covers every descendant. - It can put the user into a typed `context` with `createContext` and `context.set`, so loaders read it with `context.get` instead of fetching the session again. - In TypeScript, a module augmentation of the `Future` interface with `v8_middleware: true` is needed for `context` to be typed properly. ## Fix 2: check in every loader Call a shared `await requireUser(request)` at the top of each protected loader. It works in any 7.x version and needs no new concepts, but it relies on discipline: one forgotten loader reintroduces the leak. ## Other options, briefly | Option | Stops child requests | Cost | |---|---|---| | `middleware` on the guard route | yes | needs 7.9.0+; a new concept for the team | | `requireUser()` in every loader | yes, if nobody forgets | repetition; easy to miss one | | custom `dataStrategy` that runs parents first | yes | reintroduces waterfalls for every route; advanced API | | wrapper component with `<Navigate>` | no — loaders already ran | only affects rendering | ## How to confirm the diagnosis 1. Open the network panel, sign out, and paste the deep link `/admin/users` into the address bar. 2. Look for the admin API request with a 401 or 403 status alongside the navigation to `/login`. 3. Add a log line at the top of the child loader; it prints even though the user never sees the page. If the request is there, the guard is only deciding the destination, not preventing work. After moving the check into middleware, repeat the test: the admin request should disappear entirely. ## Why this was never security The request that "leaked" was made by the browser, from code the user controls. Anyone can call `/api/admin/users` with devtools or `curl`, with or without React Router. The 401 in the network panel is the system working: the **server** refused. Client-side guards exist to give a good experience — no flash of admin UI, a clean redirect, a return to the deep link. Authorization lives on the API, checked on every request, for every role. ## What to say in an interview Name the mechanism — parallel loaders, redirect handled after settling — then the version-appropriate fix — middleware in 7.9.0+, or a check in every loader — and close with the server-side authorization point. That combination is what separates someone who has run a data router in production from someone who has read the guard example.
- Why doesn't React Router simply run parent loaders before child loaders?Sequential loading would turn every nested route into a request waterfall: each level would wait for its parent before starting. Running loaders in parallel is the data router's main performance feature. Middleware is the explicit opt-in for code that must run first, and a custom `dataStrategy` is the escape hatch for changing the order globally.
- Does the parallel-loader problem also affect sibling data on the same page, not just auth?Yes. Any loader that assumes something a parent loader establishes — a tenant, a feature flag, a resolved record — cannot rely on the parent having finished. Shared prerequisites belong in middleware or in a helper each loader awaits, often memoised per request.
A kitchen that starts every course the moment an order arrives: if the manager then finds the table has no reservation, the starter and main are already cooking. Only checking the reservation at the door, before the order reaches the kitchen, keeps the stove cold - that door check is middleware.
saying these in an interview costs you the question
- A parent loader that throws redirect stops its child loaders from running
- The router aborts in-flight child loaders as soon as a redirect is thrown
- Since the user ended on /login, nothing was requested from the admin API
- Middleware in data mode runs after the loaders
- Adding a RequireAuth wrapper component prevents the child loader's request
- Client-side route guards are sufficient authorization for admin data