Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~35 min· topic 6 of 6

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.

RequireAuth.jsxwhole filejsx
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.

Login.jsxwhole filejsx
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 });
  }
  // ...
}
Send the user back after logindiagram
Rendering diagram…

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.

routes.jsxwhole filejsx
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.

lazy-routes.jsxwhole filejsx
// 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.

Root.jsxwhole filejsx
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.

usePageFocus.jswhole filejsx
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. 1

    Forget replace

    In the guard example, remove replace from <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. 2

    Protect a second page

    Add an /orders page wrapped in RequireAuth and a link to it. Log in from the account redirect, then click Orders: is it shown without a second login? Why?

  3. 3

    See the fallback

    In the React.lazy example, move the <Suspense> boundary so it wraps only <Settings /> inside its element. Does the fallback appear now? (The transition only keeps already shown content visible; a brand-new boundary has nothing old to keep.)

Code & diagrams

An auth guard with redirect back after login New tab

`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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

start: /login | Log in
after login: /account | Account of Asha
after logout: /login | Log in
Lazy route, pending link, page title and focus (data router) React 19+ New tab

The 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

Press Run to see the page.

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 | Shop
React.lazy routes and ScrollToTop (declarative mode) New tab

While 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

Press Run to see the page.

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: Settings
Route middleware for auth (data mode, v8)tsx
import { 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();.

terminal
$ Console (development build)
── what you'll see ──
You should call navigate() in a React.useEffect(), not when your component is first rendered.

Break #2

Trusting an unchecked redirect target

Read the target from the query string and navigate to it without checking: navigate(searchParams.get("from")).

terminal
$ Security review
── what you'll see ──
A login link with a crafted from value can send users to an outside site right after they type their password (open redirect).

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 ScrollRestoration saves positions in sessionStorage (key configurable with storageKey) and sets history.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 with useBeforeUnload for tab closes and reloads, which the router can't intercept.

  • ▸

    Since v7, BrowserRouter and RouterProvider wrap updates in startTransition by default. A route that suspends (React.lazy, use()) keeps the previous screen instead of showing the nearest fallback, so pending UI should come from useNavigation/isPending (data routers) or a useTransition-style indicator.

Remember this

  1. 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 RequireAuth that 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 calls navigate in an effect for you, so it's safe to use in render). Use replace so 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. 2

    Redirect back after login. The guard passes where the user was going in location state ({ from: "/account" }). The login page reads location.state?.from, and after a successful login calls navigate(from, { replace: true }). Validate from before 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. 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. 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 object lazy: async () => ({ Component, loader }); the router downloads the module during the navigation, in parallel with other loaders, and useNavigation() reports loading meanwhile. Lazy-load big, rarely used pages (admin, settings, charts), not the home page.

  5. 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. getKey lets 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 no ScrollRestoration, so a small ScrollToTop component calls window.scrollTo(0, 0) in an effect when the pathname changes.

  6. 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.title on 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 (with tabIndex={-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

01

How do you build a protected route in React Router, and why use replace?

02

Why is a client-side auth guard not a security boundary?

03

How do lazy routes work in declarative and data mode, and what does the user see while code loads?

04

What must an SPA do after navigation that a full page load did automatically?

Practice

01

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.

02

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".

03

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 remembers from.

  • 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 + Suspense or the lazy route property.

  • I can handle scroll with ScrollRestoration or a ScrollToTop effect.

  • I update the title and move focus after each navigation.