Topic 13.6
Protected Routes and Navigation UX
In one line
Protect pages by redirecting logged-out users to a login page with <Navigate> (or redirect in a loader) and sending them back afterwards; load route code only when it's needed; and do what a full page load did for free: reset or restore scroll, move keyboard focus to the new content, and update the page title.
Think of it like this
A cinema. The ticket checker at the door of screen 3 (the guard) stops you if you have no ticket and sends you to the box office (the login page), but remembers you wanted screen 3, so after you buy a ticket you go straight back there. Once inside, an usher points a torch at your seat (moving focus), the sign above the door shows the film's name (the page title), and if you step out and come back, you find your seat where you left it (scroll restoration).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Protected route
- A page only allowed for some users, such as logged-in ones. Others are redirected.
- Auth guard
- The component or loader that checks the user and redirects when they aren't allowed.
- Navigate
<Navigate to>: a component that redirects as soon as it renders.- redirect
- A function that creates a redirect response; thrown or returned from loaders and actions in data routers.
- Open redirect
- A bug where an attacker can make your site send users to any URL, for example through an unchecked
?from=value. - Lazy route
- A route whose code downloads only when it is first visited.
- Code splitting
- Breaking the JavaScript bundle into pieces that load on demand.
- ScrollRestoration
- A data-router component that resets scroll on new pages and restores it on Back/Forward.
- Focus management
- Moving keyboard focus to a sensible place (like the new page's heading) after the screen changes.
- Live region
- An element (
role="status",aria-live) whose text changes are read out by screen readers.
Step by step
01Write the guard
The guard renders its children (or an <Outlet />) when the user is allowed, and a <Navigate> otherwise. It stores where the user wanted to go.
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuth } from "./auth.jsx"; // your context from Topic 9.2
export function RequireAuth() {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
}
return <Outlet />;
}
// Protect a whole group with a layout route:
<Route element={<RequireAuth />}>
<Route path="account" element={<Account />} />
<Route path="orders" element={<Orders />} />
</Route>02Send the user back after login
Read from, check it is a local path, log in, then navigate(from, { replace: true }) so Back doesn't return to the login form.
function safePath(value) {
// only local paths like "/account", never "//evil.example" or "https://..."
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : "/";
}
export function Login() {
const { login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const from = safePath(location.state?.from);
async function handleSubmit(e) {
e.preventDefault();
await login(new FormData(e.currentTarget));
navigate(from, { replace: true });
}
// ...
}03Guard in a loader (data mode)
A loader runs before the route renders, so a logged-out user never sees a flash of the protected page. Put the check on a parent route to cover its children.
async function requireUser({ request }) {
const user = await getCurrentUser();
if (!user) {
const url = new URL(request.url);
throw redirect("/login?from=" + encodeURIComponent(url.pathname));
}
return { user };
}
const router = createBrowserRouter([
{ path: "/login", Component: Login },
{
id: "app",
loader: requireUser, // runs for every child below
Component: AppLayout,
children: [{ path: "account", Component: Account }],
},
]);04Split rarely used pages
Declarative: React.lazy plus Suspense. Data mode: the route's lazy property, which also lets the loader live in the lazy file.
// Declarative mode
const Reports = lazy(() => import("./pages/Reports.jsx"));
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="reports" element={<Reports />} />
</Routes>
</Suspense>
// Data mode
{
path: "reports",
lazy: async () => {
const { Component, loader } = await import("./pages/reports.jsx");
return { Component, loader };
},
}05Restore scroll
Data routers: one <ScrollRestoration /> in the root layout. Declarative: a ScrollToTop component.
export function Root() {
return (
<>
<Header />
<Outlet />
<ScrollRestoration getKey={(location) => location.pathname} />
</>
);
}
// keep the scroll position for a tab switch
<Link to="?tab=reviews" preventScrollReset>Reviews</Link>06Title and focus on every page
Render a <title> in each page (React 19). In the root layout, after a navigation (not the first load), focus the new page's <h1>, which has tabIndex={-1} so script can focus it without adding it to the Tab order.
function usePageFocus(containerRef) {
const { pathname } = useLocation();
const lastPath = useRef(pathname);
useEffect(() => {
if (lastPath.current === pathname) return; // first load: leave focus alone
lastPath.current = pathname;
containerRef.current?.querySelector("h1")?.focus();
}, [pathname, containerRef]);
}Try it yourself
- 1
Forget replace
In the guard example, remove
replacefrom<Navigate>. Predict: after the redirect, how many entries are in the history? Add a Back button (navigate(-1)) on the login page and see where it takes you. - 2
Protect a second page
Add an
/orderspage wrapped inRequireAuthand a link to it. Log in from the account redirect, then click Orders: is it shown without a second login? Why? - 3
See the fallback
In the React.lazy example, move the
<Suspense>boundary so it wraps only<Settings />inside itselement. Does the fallback appear now? (The transition only keeps already shown content visible; a brand-new boundary has nothing old to keep.)
Code & diagrams
`initialEntries={["/", "/account"]}` starts as if the user came from Home and opened `/account`. The guard redirects to `/login` with `from`. After login, the user lands on `/account`, and logging out makes the guard redirect again at once. Try the My account link before and after logging in.
Preview
Console
Nothing logged yet.Expected console output
start: /login | Log in
after login: /account | Account of Asha
after logout: /login | Log inThe reports code downloads only on the first visit. Meanwhile `useNavigation` and `NavLink`'s `isPending` show progress. React 19 moves each page's `<title>` into `<head>`, and the root layout focuses the new `<h1>` so keyboard and screen reader users start on the new content.
Preview
Console
Nothing logged yet.Expected console output
title at start: Home | Shop
downloading the reports code...
pending: Loading page... | Reports (loading...)
focus on: Reports | title: Reports | ShopWhile Settings downloads, the page still shows **Home**, not the Suspense fallback: React Router wraps navigations in `startTransition`, and React keeps the old UI during a transition (Topic 14.3). The pathname (and the scroll effect) only changes once Settings is ready. The second visit doesn't download again.
Preview
Console
Nothing logged yet.Expected console output
scrolled to top for /
downloading Settings...
while loading: Home
scrolled to top for /settings
after: Settings
scrolled to top for /
scrolled to top for /settings
second visit: Settingsimport { createContext, redirect } from "react-router";
export const userContext = createContext<User | null>(null);
async function authMiddleware({ request, context }) {
const user = await getCurrentUser(request);
if (!user) throw redirect("/login");
context.set(userContext, user); // loaders below read it with context.get(userContext)
}
createBrowserRouter([
{ path: "/login", Component: Login },
{ path: "/app", middleware: [authMiddleware], Component: AppLayout, children: [/* ... */] },
]);Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Call navigate during render instead of using Navigate
Write the guard as if (!user) { navigate("/login"); return null; } with const navigate = useNavigate();.
Break #2
Trusting an unchecked redirect target
Read the target from the query string and navigate to it without checking: navigate(searchParams.get("from")).
Myth vs fact
Myth
A protected route keeps data safe.
Fact
Client guards only hide screens. The server must check permissions on every request, because users control their own browser.
Myth
Lazy loading every route makes the app faster.
Fact
Each lazy route adds a network round-trip on first visit. Split large, rarely used pages; keep small and common pages in the main bundle, and preload likely next pages.
Myth
The browser handles focus and titles in an SPA.
Fact
It only does that on full page loads. After client-side navigation you must update the title and move or announce focus yourself.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
<Navigate>in declarative mode renders the protected page's parent first and redirects in an effect, so there is one commit before the redirect. Loader-based guards (throw redirect) or middleware avoid rendering anything protected at all, and avoid loading the protected page's data. - ▸
React Router's
ScrollRestorationsaves positions insessionStorage(key configurable withstorageKey) and setshistory.scrollRestoration = "manual"; it waits until the new route has rendered (after loaders) before restoring, which is why it's only available in data routers. - ▸
useBlocker(data routers) lets you stop navigation when a form has unsaved changes and show your own confirm dialog; pair it withuseBeforeUnloadfor tab closes and reloads, which the router can't intercept. - ▸
Since v7,
BrowserRouterandRouterProviderwrap updates instartTransitionby default. A route that suspends (React.lazy,use()) keeps the previous screen instead of showing the nearest fallback, so pending UI should come fromuseNavigation/isPending(data routers) or auseTransition-style indicator.
Remember this
- 1
A protected route (or auth guard) is a page only some users may see, such as "My account". In declarative mode, wrap the page in a component like
RequireAuththat checks the user and, if there is none, renders **<Navigate to="/login" replace state={{ from: location.pathname }} />**.<Navigate>is a component that navigates as soon as it renders (it callsnavigatein an effect for you, so it's safe to use in render). Usereplaceso the protected URL doesn't stay in history: otherwise pressing Back from the login page would bounce straight back into the guard, which redirects again, trapping the user. Put the guard on a layout route (<Route element={<RequireAuth />}>rendering<Outlet />) to protect a whole group of pages at once. - 2
Redirect back after login. The guard passes where the user was going in location
state({ from: "/account" }). The login page readslocation.state?.from, and after a successful login callsnavigate(from, { replace: true }). Validatefrombefore using it: only accept paths that start with a single/, never a full URL from the query string, or attackers can craft login links that bounce users to their own site (an open redirect, Phase 21). In data mode, a loader can do the same check before anything renders:if (!user) throw redirect("/login?from=" + encodeURIComponent(url.pathname)), and version 8's route middleware can do it once for a whole branch. - 3
Guards in the browser are for user experience, not security. All your JavaScript is downloaded to the user's device, where they can read it and change it. A client guard only hides screens; the server must check permission on every API request and refuse data the user may not see. Never put secrets in a "protected" route's code. The client guard's job is to avoid showing a broken page and to send people to log in.
- 4
Lazy routes split your bundle: the code for a page is downloaded only when someone visits it, so the first load is smaller. In declarative mode use React's
lazy(() => import("./Settings.jsx"))for the page component and wrap the routes in<Suspense fallback={...}>(Topic 14.3). Because React Router wraps navigations in transitions, React keeps the old page visible while the new code downloads instead of flashing the fallback. In data mode, give the route objectlazy: async () => ({ Component, loader }); the router downloads the module during the navigation, in parallel with other loaders, anduseNavigation()reportsloadingmeanwhile. Lazy-load big, rarely used pages (admin, settings, charts), not the home page. - 5
Scroll. On a real page load the browser starts at the top and restores the old position on Back. In an SPA the router must do it. Data routers provide **
<ScrollRestoration />**: render it once in the root route and it scrolls to the top on new navigations, restores saved positions on Back/Forward, and scrolls to the element with the matching id when the URL has a#hash.getKeylets you restore by pathname instead of by history entry, and<Link preventScrollReset>keeps the position for in-page changes like switching tabs. Declarative mode has noScrollRestoration, so a smallScrollToTopcomponent callswindow.scrollTo(0, 0)in an effect when the pathname changes. - 6
Focus and titles. After a full page load, screen readers announce the new page title and keyboard focus starts at the top. After a client-side navigation, nothing happens by default: focus stays on the link that was clicked (which may no longer exist), and screen reader users may not know the page changed. Good routing apps (1) update
document.titleon every page, which in React 19 is as easy as rendering<title>Reports | Shop</title>inside the page component (React moves it into<head>), and (2) move focus to the new page's main heading (withtabIndex={-1}so it can be focused) or announce the change in a live region. Skip the focus move on the very first load, when the browser already did its job. Phase 18 covers accessible navigation in depth.
Explain it without notes
How do you build a protected route in React Router, and why use replace?
Why is a client-side auth guard not a security boundary?
How do lazy routes work in declarative and data mode, and what does the user see while code loads?
What must an SPA do after navigation that a full page load did automatically?
Practice
Make an AdminOnly layout route that lets through users with role === "admin", sends logged-out users to /login, and shows Not allowed (no redirect) to logged-in non-admins.
Write a safeFrom(value) function that returns value only if it's a local path, otherwise "/", and test it with "/orders", "//evil.example" and "https://evil.example".
Give three pages a React 19 <title> and log document.title after clicking between them in a memory router.
Trade-offs
- ↔
Component guards are simple and work in declarative mode, but the protected page's code (and sometimes a render) happens before the redirect. Loader or middleware guards stop earlier but require data mode.
- ↔
Lazy routes shrink the first download but add a delay on first visit to each page. Combine with pending UI, and consider prefetching (framework mode
<Link prefetch>) for likely next pages. - ↔
Moving focus on every navigation helps keyboard and screen reader users, but must not steal focus during in-page changes (tabs, filters). Decide per navigation type, and test with a keyboard.
Done when you can
I can protect pages with a guard that renders
<Navigate replace>and remembersfrom.I can send users back after login and validate the redirect target.
I know client guards are UX only and the server must enforce access.
I can lazy-load routes with
React.lazy+Suspenseor thelazyroute property.I can handle scroll with
ScrollRestorationor aScrollToTopeffect.I update the title and move focus after each navigation.