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?
answer
- history entry versus URL
- navigate with replace
- the action can return redirect()
- one leading slash, never two
basics
~20 sAfter 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 '/'.
solid answer
~40 sThere are two carriers. `location.state.from`, set by a `<Navigate state>` guard, lives on the history entry: it cannot be forged through a link, but it is lost in a new tab. A `?redirectTo=` param, set by a loader guard, survives new tabs and bookmarks, but anyone can craft it. To return the user, a component calls `navigate(target, { replace: true })` so `/login` drops out of Back; in a data router the login `action` returns `redirect(target)`. Validate before either: React Router's own docs say `redirect()` accepts absolute URLs and can navigate to external domains — in 7.18.4 a cross-origin `Location` becomes a full-document navigation. So accept only values that start with `/` but not `//` or `/\`, never `/login` itself, and fall back to `/`.
code
ts · 18 linesimport { redirect, type ActionFunctionArgs } from "react-router";
import { signIn } from "./auth";
export function safeRedirect(target: unknown, fallback = "/"): string {
if (typeof target !== "string") return fallback;
if (!target.startsWith("/")) return fallback; // absolute or relative URL
if (target.startsWith("//")) return fallback; // protocol-relative
if (target.startsWith("/\\")) return fallback; // normalised to // by browsers
if (target === "/login" || target.startsWith("/login?")) return fallback;
return target;
}
export async function loginAction({ request }: ActionFunctionArgs) {
const form = await request.formData();
await signIn(form.get("email"), form.get("password"));
const redirectTo = new URL(request.url).searchParams.get("redirectTo");
return redirect(safeRedirect(redirectTo));
}go deeper
Know both carriers — location.state.from and a redirectTo param — and navigate back with replace after sign-in.
Explain which carrier survives a new tab, how a data-router login action returns redirect(target), and why the query-string carrier must be validated.
Know the router's actual behaviour: loader and action redirects to other origins become document navigations, navigate() throws on them, and one shared helper enforces a single leading slash.
Make the return-to rule a platform helper used by every sign-in path, including SSO callbacks, and review new flows for redirect targets that bypass it.
## Two ways the destination reaches the login page The scenario: a signed-out user opens a deep link to `/admin/users?tab=roles`, a guard sends them to `/login`, and after signing in they should land on the exact page they asked for. The guard can hand the destination over in two ways. | | `location.state.from` | `?redirectTo=` search param | |---|---|---| | Set by | `<Navigate to="/login" state={{ from: location }} />` | a loader throwing `redirect("/login?redirectTo=…")` | | Stored in | the history entry, not the URL | the URL itself | | New tab, bookmark, shared link | lost | kept | | Who can set it | only your code | anyone who can send the user a link | | Needs validation | still worth a type check | **mandatory** | ## Sending the user back **Component flow** (declarative mode, or a login form without an action): 1. Read `const from = location.state?.from` from `useLocation()`. 2. After the sign-in call succeeds, call `navigate(from?.pathname ?? "/", { replace: true })`, appending `from.search` if you need the query string. 3. `replace: true` swaps the `/login` entry out, so Back goes to the page before the login flow instead of back into the form. **Data-router flow** (login page with an `action`): 1. The `<Form>` posts to the login route's `action`. 2. The action reads `redirectTo` from `new URL(request.url).searchParams` (or from a hidden form field). 3. It signs the user in and returns `redirect(safeTarget)`. The router follows it after the action completes. ## Why validation is required in this router's terms `redirectTo` arrives from the URL, so an attacker can send a victim a link such as `/login?redirectTo=https://phish.example/`. React Router does not protect you here: - `redirect()`'s own documentation says it *"accepts absolute URLs and can navigate to external domains, so the application should validate any user-supplied inputs to redirects"*. - In the pinned 7.18.4 runtime, when a loader or action redirect points at another origin, the router performs a **full-document navigation** with `window.location` instead of a client-side one — the user leaves your app. - `navigate()` and `<Navigate>` in the same version **throw** `External navigation is not allowed` for cross-origin targets. That blocks the worst case in the component flow, but it is a crash, not validation, and it does nothing for same-origin targets you did not intend, such as `/logout`. ## A safe check Accept a candidate only when all of these hold, otherwise use a default like `/`: - it is a string that **starts with `/`**, - it does **not** start with `//` (a protocol-relative URL, which browsers treat as another host), - it does **not** start with `/\` (browsers normalise a backslash to a slash in URLs, turning it into `//`), - it is **not the login route itself**, which would loop. Keep the helper in one module and use it in both flows, so the rules cannot drift. ## Choosing a carrier Use `location.state` when the guard is a wrapper component and you do not need deep links to survive a new tab — it needs no validation beyond a shape check, because only your code can put it there. Use a `redirectTo` search param when the guard is a loader, when sign-in can happen in another tab or through an external identity provider, or when the login URL may be bookmarked. Many apps use both: prefer `state.from` when present and fall back to a validated `redirectTo`. ## Putting the reserved scenario together 1. The `/admin` subtree is guarded; a loader throws `redirect("/login?" + new URLSearchParams({ redirectTo: "/admin/users?tab=roles" }))`. 2. The login action validates `redirectTo`, signs in, and returns `redirect("/admin/users?tab=roles")`. 3. The guard now passes; the admin loaders run; the user lands on the tab they asked for, and Back does not return to the form in the component flow. Open-redirect vulnerabilities as a class are an application-security topic; the React Router part is knowing that `redirect()` will happily leave your origin, and that the check belongs in your code.
- If navigate() already throws for cross-origin targets in 7.18.4, why validate in the component flow?Because throwing is not handling: the user sees an error instead of landing somewhere sensible, and the check does nothing for same-origin targets you did not intend, such as `/logout` or `/login`. A helper that falls back to `/` gives predictable behaviour on every path.
- Why pass replace: true when navigating back after sign-in?Without it, history holds `/login` between the previous page and the destination, so Back from the destination reopens the login form for an already signed-in user. `replace: true` swaps the login entry for the destination.
saying these in an interview costs you the question
- React Router's redirect() refuses external URLs, so redirectTo needs no check
- Checking that redirectTo starts with '/' is enough on its own
- location.state.from survives opening the login link in a new tab
- After sign-in, navigate without replace so Back returns to the login form
- A redirectTo pointing at /login itself is harmless